From 8fb6453ac2c622cd28cc46d1911cae16bb0bebe2 Mon Sep 17 00:00:00 2001 From: sophiathedev Date: Tue, 18 Aug 2026 15:57:37 +0700 Subject: [PATCH 1/5] feat(sidebar): add favorite databases --- CHANGELOG.md | 1 + TablePro/Core/Storage/ConnectionStorage.swift | 4 + .../Storage/FavoriteDatabasesStorage.swift | 87 +++++++++++++ TablePro/Core/Sync/SyncCoordinator.swift | 4 + .../Favorites/FavoriteDatabaseEntry.swift | 40 ++++++ .../FavoriteDatabaseEnvironment.swift | 38 ++++++ .../FavoriteDatabaseEnvironmentFilter.swift | 34 +++++ .../Favorites/FavoriteDatabaseGroup.swift | 13 ++ .../Favorites/FavoriteDatabaseGrouping.swift | 33 +++++ TablePro/Models/UI/SharedSidebarState.swift | 14 +++ .../ViewModels/FavoritesExpansionState.swift | 49 ++++++++ ...abaseTreeOutlineCoordinator+Commands.swift | 8 ++ .../DatabaseTreeOutlineCoordinator+Menu.swift | 7 ++ .../DatabaseTreeOutlineCoordinator.swift | 57 +++++++-- .../Views/Sidebar/DatabaseTreeRowView.swift | 29 ++++- .../Sidebar/FavoriteDatabaseFilterBar.swift | 30 +++++ .../Views/Sidebar/FavoritesExpansion.swift | 19 +++ .../Sidebar/FavoritesOutlineCoordinator.swift | 72 +++++++++-- .../Views/Sidebar/FavoritesOutlineNode.swift | 21 +++- .../Sidebar/FavoritesOutlineSelection.swift | 8 ++ .../Views/Sidebar/FavoritesOutlineView.swift | 4 + TablePro/Views/Sidebar/FavoritesTabView.swift | 118 +++++++++++++++++- .../Sidebar/Menu/DatabaseTreeMenuSpec.swift | 30 +++++ .../Sidebar/Menu/FavoritesMenuCommand.swift | 4 + .../Sidebar/Menu/FavoritesMenuSpec.swift | 44 ++++++- .../Sidebar/Menu/SidebarMenuCommand.swift | 2 + .../Views/Sidebar/SidebarPersistenceKey.swift | 4 + TablePro/Views/Sidebar/SidebarView.swift | 1 + .../FavoriteDatabasesStorageTests.swift | 116 +++++++++++++++++ .../FavoriteDatabaseGroupingTests.swift | 97 ++++++++++++++ .../Models/SharedSidebarStateTests.swift | 18 ++- .../Sidebar/DatabaseTreeMenuSpecTests.swift | 29 +++++ .../Sidebar/FavoritesMenuSpecTests.swift | 33 +++++ .../FavoritesOutlineSelectionTests.swift | 49 ++++++++ .../Sidebar/SidebarOutlineScaffoldTests.swift | 81 ++++++++++++ docs/features/favorites.mdx | 12 +- 36 files changed, 1175 insertions(+), 35 deletions(-) create mode 100644 TablePro/Core/Storage/FavoriteDatabasesStorage.swift create mode 100644 TablePro/Models/Favorites/FavoriteDatabaseEntry.swift create mode 100644 TablePro/Models/Favorites/FavoriteDatabaseEnvironment.swift create mode 100644 TablePro/Models/Favorites/FavoriteDatabaseEnvironmentFilter.swift create mode 100644 TablePro/Models/Favorites/FavoriteDatabaseGroup.swift create mode 100644 TablePro/Models/Favorites/FavoriteDatabaseGrouping.swift create mode 100644 TablePro/Views/Sidebar/FavoriteDatabaseFilterBar.swift create mode 100644 TableProTests/Core/Storage/FavoriteDatabasesStorageTests.swift create mode 100644 TableProTests/Models/FavoriteDatabaseGroupingTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e5f14c9d..0818f646a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Databases can be saved to the Favorites sidebar and grouped as Development, Testing, Production or Unassigned. Right-click a database in the Tree sidebar to add it, then use the environment filter or activate it directly from Favorites. (#1553) - Tree sidebar layouts group tables, views, materialized views, foreign tables, procedures and functions into collapsible folders inside each database or schema. (#1590) - Dameng DM8 connections through a downloadable native-wire plugin, with schema browsing, table editing, metadata, DDL, transactions, Unicode and binary writes, EXPLAIN support, and query cancellation with a query timeout. Stopping a DM8 query closes its connection, because DM8 has no out-of-band cancel request; TablePro reconnects on the next query. (#1671, #2003, #2010) - SQL Server connections can sign in with Microsoft Entra ID, covering Azure SQL Database, Azure SQL Managed Instance, and SQL Server 2022. Sign-in runs in your browser and honours multifactor authentication and Conditional Access; tokens are kept in the keychain and refreshed for you. Set it up on the Mac; iPhone and iPad pick the connection up through sync and prompt to sign in when you open it. diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index 61cdb307d..41ca0945b 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -291,6 +291,8 @@ final class ConnectionStorage { appSettings.saveLastSchema(nil, for: connection.id) FavoriteTablesStorage.shared.removeFavorites(for: connection.id) + FavoriteDatabasesStorage.shared.removeFavorites(for: connection.id) + FavoritesExpansionState.shared.removeConnection(connection.id) FilterSettingsStorage.shared.removeFilters(for: connection.id) DatabaseTreeFilterStorage.shared.removeFilter(for: connection.id) RecentlyClosedTabStore.shared.removeEntries(for: connection.id) @@ -331,6 +333,8 @@ final class ConnectionStorage { appSettings.saveLastDatabase(nil, for: conn.id) appSettings.saveLastSchema(nil, for: conn.id) FavoriteTablesStorage.shared.removeFavorites(for: conn.id) + FavoriteDatabasesStorage.shared.removeFavorites(for: conn.id) + FavoritesExpansionState.shared.removeConnection(conn.id) } FilterSettingsStorage.shared.removeFilters(for: idsToDelete) DatabaseTreeFilterStorage.shared.removeFilters(for: idsToDelete) diff --git a/TablePro/Core/Storage/FavoriteDatabasesStorage.swift b/TablePro/Core/Storage/FavoriteDatabasesStorage.swift new file mode 100644 index 000000000..eafc4e877 --- /dev/null +++ b/TablePro/Core/Storage/FavoriteDatabasesStorage.swift @@ -0,0 +1,87 @@ +// +// FavoriteDatabasesStorage.swift +// TablePro +// + +import Foundation +import os + +extension Notification.Name { + internal static let favoriteDatabasesDidChange = Notification.Name("FavoriteDatabasesDidChange") +} + +@MainActor +internal final class FavoriteDatabasesStorage { + internal static let shared = FavoriteDatabasesStorage() + + private static let logger = Logger(subsystem: "com.TablePro", category: "FavoriteDatabasesStorage") + private let defaults: UserDefaults + + internal init(defaults: UserDefaults = AppStorageEnvironment.shared.defaults) { + self.defaults = defaults + } + + internal func favorites(for connectionId: UUID) -> Set { + guard let data = defaults.data(forKey: key(for: connectionId)), + let decoded = try? JSONDecoder().decode(Set.self, from: data) + else { return [] } + return decoded.filter { $0.connectionId == connectionId && !$0.database.isEmpty } + } + + internal func environment( + for database: String, + connectionId: UUID + ) -> FavoriteDatabaseEnvironment? { + favorites(for: connectionId).first { $0.database == database }?.environment + } + + internal func setFavorite( + database: String, + environment: FavoriteDatabaseEnvironment, + connectionId: UUID + ) { + guard !database.isEmpty else { return } + var entries = favorites(for: connectionId) + entries = Set(entries.filter { $0.database != database }) + entries.insert(FavoriteDatabaseEntry( + connectionId: connectionId, + database: database, + environment: environment + )) + persist(entries, connectionId: connectionId) + } + + internal func removeFavorite(database: String, connectionId: UUID) { + var entries = favorites(for: connectionId) + let originalCount = entries.count + entries = Set(entries.filter { $0.database != database }) + guard entries.count != originalCount else { return } + persist(entries, connectionId: connectionId) + } + + internal func removeFavorites(for connectionId: UUID) { + let key = key(for: connectionId) + guard defaults.object(forKey: key) != nil else { return } + defaults.removeObject(forKey: key) + NotificationCenter.default.post(name: .favoriteDatabasesDidChange, object: self) + } + + private func persist(_ entries: Set, connectionId: UUID) { + let key = key(for: connectionId) + guard !entries.isEmpty else { + defaults.removeObject(forKey: key) + NotificationCenter.default.post(name: .favoriteDatabasesDidChange, object: self) + return + } + do { + defaults.set(try JSONEncoder().encode(entries), forKey: key) + NotificationCenter.default.post(name: .favoriteDatabasesDidChange, object: self) + } catch { + Self.logger.error("Failed to encode favorite databases: \(error.localizedDescription, privacy: .public)") + } + } + + private func key(for connectionId: UUID) -> String { + "com.TablePro.favoriteDatabases.\(connectionId.uuidString)" + } +} diff --git a/TablePro/Core/Sync/SyncCoordinator.swift b/TablePro/Core/Sync/SyncCoordinator.swift index a2917b34b..be0077e13 100644 --- a/TablePro/Core/Sync/SyncCoordinator.swift +++ b/TablePro/Core/Sync/SyncCoordinator.swift @@ -519,6 +519,10 @@ final class SyncCoordinator { Self.logger.error("Failed to apply remote connection deletions: persistence error") } else { FilterSettingsStorage.shared.removeFilters(for: connectionIdsToDelete) + for id in connectionIdsToDelete { + FavoriteDatabasesStorage.shared.removeFavorites(for: id) + FavoritesExpansionState.shared.removeConnection(id) + } let favoriteManager = services.sqlFavoriteManager Task { for id in connectionIdsToDelete { diff --git a/TablePro/Models/Favorites/FavoriteDatabaseEntry.swift b/TablePro/Models/Favorites/FavoriteDatabaseEntry.swift new file mode 100644 index 000000000..f35de96a9 --- /dev/null +++ b/TablePro/Models/Favorites/FavoriteDatabaseEntry.swift @@ -0,0 +1,40 @@ +// +// FavoriteDatabaseEntry.swift +// TablePro +// + +import Foundation + +internal struct FavoriteDatabaseEntry: Codable, Hashable, Identifiable, Sendable { + internal let connectionId: UUID + internal let database: String + internal let environment: FavoriteDatabaseEnvironment + + internal var id: String { + "\(connectionId.uuidString)\u{1}\(database)" + } + + internal init( + connectionId: UUID, + database: String, + environment: FavoriteDatabaseEnvironment + ) { + self.connectionId = connectionId + self.database = database + self.environment = environment + } + + private enum CodingKeys: String, CodingKey { + case connectionId + case database + case environment + } + + internal init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + connectionId = try container.decode(UUID.self, forKey: .connectionId) + database = try container.decode(String.self, forKey: .database) + let rawEnvironment = try container.decodeIfPresent(String.self, forKey: .environment) + environment = rawEnvironment.flatMap(FavoriteDatabaseEnvironment.init(rawValue:)) ?? .none + } +} diff --git a/TablePro/Models/Favorites/FavoriteDatabaseEnvironment.swift b/TablePro/Models/Favorites/FavoriteDatabaseEnvironment.swift new file mode 100644 index 000000000..b3f76ff3c --- /dev/null +++ b/TablePro/Models/Favorites/FavoriteDatabaseEnvironment.swift @@ -0,0 +1,38 @@ +// +// FavoriteDatabaseEnvironment.swift +// TablePro +// + +import Foundation + +internal enum FavoriteDatabaseEnvironment: String, CaseIterable, Codable, Sendable { + case development + case testing + case production + case none + + internal var title: String { + switch self { + case .development: String(localized: "Development") + case .testing: String(localized: "Testing") + case .production: String(localized: "Production") + case .none: String(localized: "Unassigned") + } + } + + internal var menuTitle: String { + switch self { + case .none: String(localized: "No Environment") + case .development, .testing, .production: title + } + } + + internal var iconName: String { + switch self { + case .development: "wrench.and.screwdriver" + case .testing: "checkmark.circle" + case .production: "lock.shield" + case .none: "tray" + } + } +} diff --git a/TablePro/Models/Favorites/FavoriteDatabaseEnvironmentFilter.swift b/TablePro/Models/Favorites/FavoriteDatabaseEnvironmentFilter.swift new file mode 100644 index 000000000..2d98dde05 --- /dev/null +++ b/TablePro/Models/Favorites/FavoriteDatabaseEnvironmentFilter.swift @@ -0,0 +1,34 @@ +// +// FavoriteDatabaseEnvironmentFilter.swift +// TablePro +// + +import Foundation + +internal enum FavoriteDatabaseEnvironmentFilter: String, CaseIterable, Sendable { + case all + case development + case testing + case production + case unassigned + + internal var title: String { + switch self { + case .all: String(localized: "All Environments") + case .development: FavoriteDatabaseEnvironment.development.title + case .testing: FavoriteDatabaseEnvironment.testing.title + case .production: FavoriteDatabaseEnvironment.production.title + case .unassigned: FavoriteDatabaseEnvironment.none.title + } + } + + internal var environment: FavoriteDatabaseEnvironment? { + switch self { + case .all: nil + case .development: .development + case .testing: .testing + case .production: .production + case .unassigned: FavoriteDatabaseEnvironment.none + } + } +} diff --git a/TablePro/Models/Favorites/FavoriteDatabaseGroup.swift b/TablePro/Models/Favorites/FavoriteDatabaseGroup.swift new file mode 100644 index 000000000..236411941 --- /dev/null +++ b/TablePro/Models/Favorites/FavoriteDatabaseGroup.swift @@ -0,0 +1,13 @@ +// +// FavoriteDatabaseGroup.swift +// TablePro +// + +import Foundation + +internal struct FavoriteDatabaseGroup: Equatable, Identifiable, Sendable { + internal let environment: FavoriteDatabaseEnvironment + internal let entries: [FavoriteDatabaseEntry] + + internal var id: String { environment.rawValue } +} diff --git a/TablePro/Models/Favorites/FavoriteDatabaseGrouping.swift b/TablePro/Models/Favorites/FavoriteDatabaseGrouping.swift new file mode 100644 index 000000000..0f6a06124 --- /dev/null +++ b/TablePro/Models/Favorites/FavoriteDatabaseGrouping.swift @@ -0,0 +1,33 @@ +// +// FavoriteDatabaseGrouping.swift +// TablePro +// + +import Foundation + +internal enum FavoriteDatabaseGrouping { + internal static func groups( + entries: Set, + searchText: String, + filter: FavoriteDatabaseEnvironmentFilter + ) -> [FavoriteDatabaseGroup] { + let filtered = entries.filter { entry in + guard filter.environment == nil || entry.environment == filter.environment else { return false } + guard !searchText.isEmpty else { return true } + return entry.database.localizedStandardContains(searchText) + || entry.environment.title.localizedStandardContains(searchText) + } + + return FavoriteDatabaseEnvironment.allCases.compactMap { environment in + let matching = filtered + .filter { $0.environment == environment } + .sorted { + let comparison = $0.database.localizedStandardCompare($1.database) + if comparison != .orderedSame { return comparison == .orderedAscending } + return $0.id < $1.id + } + guard !matching.isEmpty else { return nil } + return FavoriteDatabaseGroup(environment: environment, entries: matching) + } + } +} diff --git a/TablePro/Models/UI/SharedSidebarState.swift b/TablePro/Models/UI/SharedSidebarState.swift index 544ebe7c0..9ca43a44d 100644 --- a/TablePro/Models/UI/SharedSidebarState.swift +++ b/TablePro/Models/UI/SharedSidebarState.swift @@ -112,6 +112,15 @@ final class SharedSidebarState { } } + var favoriteDatabaseEnvironmentFilter: FavoriteDatabaseEnvironmentFilter { + didSet { + AppStorageEnvironment.shared.defaults.set( + favoriteDatabaseEnvironmentFilter.rawValue, + forKey: SidebarPersistenceKey.favoriteDatabaseEnvironmentFilter(connectionId: connectionId) + ) + } + } + var selectedFavorite: FavoriteSelection? { didSet { guard oldValue != selectedFavorite else { return } @@ -156,6 +165,10 @@ final class SharedSidebarState { self.sidebarLayout = SharedSidebarState.defaultLayout } self.databaseFilterSelected = DatabaseTreeFilterStorage.shared.selectedDatabases(connectionId: connectionId) + let environmentFilterKey = SidebarPersistenceKey.favoriteDatabaseEnvironmentFilter(connectionId: connectionId) + self.favoriteDatabaseEnvironmentFilter = AppStorageEnvironment.shared.defaults + .string(forKey: environmentFilterKey) + .flatMap(FavoriteDatabaseEnvironmentFilter.init(rawValue:)) ?? .all self.selectedFavorite = AppStorageEnvironment.shared.defaults.string( forKey: SidebarPersistenceKey.selectedFavorite(connectionId: connectionId) ).flatMap(FavoriteSelection.init(rawValue:)) @@ -170,6 +183,7 @@ final class SharedSidebarState { self.selectedSidebarTab = .tables self.sidebarLayout = .flat self.databaseFilterSelected = [] + self.favoriteDatabaseEnvironmentFilter = .all self.selectedFavorite = nil } diff --git a/TablePro/ViewModels/FavoritesExpansionState.swift b/TablePro/ViewModels/FavoritesExpansionState.swift index 783dee60f..804231132 100644 --- a/TablePro/ViewModels/FavoritesExpansionState.swift +++ b/TablePro/ViewModels/FavoritesExpansionState.swift @@ -13,9 +13,12 @@ internal final class FavoritesExpansionState { private(set) var foldersByConnection: [UUID: Set] = [:] private(set) var linkedNodesByConnection: [UUID: Set] = [:] + private(set) var collapsedDatabaseEnvironmentsByConnection: [UUID: Set] = [:] @ObservationIgnored private let foldersKey = "com.TablePro.favoritesExpandedFolders" @ObservationIgnored private let linkedKey = "com.TablePro.favoritesExpandedLinkedNodes" + @ObservationIgnored private let collapsedDatabaseEnvironmentsKey = + "com.TablePro.favoritesCollapsedDatabaseEnvironments" private init() { load() @@ -29,6 +32,13 @@ internal final class FavoritesExpansionState { linkedNodesByConnection[connectionId, default: []].contains(nodeId) } + func isDatabaseEnvironmentExpanded( + _ environment: FavoriteDatabaseEnvironment, + for connectionId: UUID + ) -> Bool { + !collapsedDatabaseEnvironmentsByConnection[connectionId, default: []].contains(environment) + } + func setFolderExpanded(_ folderId: UUID, expanded: Bool, for connectionId: UUID) { var ids = foldersByConnection[connectionId] ?? [] if expanded { @@ -55,6 +65,32 @@ internal final class FavoritesExpansionState { persistLinkedNodes() } + func setDatabaseEnvironmentExpanded( + _ environment: FavoriteDatabaseEnvironment, + expanded: Bool, + for connectionId: UUID + ) { + var environments = collapsedDatabaseEnvironmentsByConnection[connectionId] ?? [] + if expanded { + guard environments.contains(environment) else { return } + environments.remove(environment) + } else { + guard !environments.contains(environment) else { return } + environments.insert(environment) + } + collapsedDatabaseEnvironmentsByConnection[connectionId] = environments + persistCollapsedDatabaseEnvironments() + } + + func removeConnection(_ connectionId: UUID) { + foldersByConnection.removeValue(forKey: connectionId) + linkedNodesByConnection.removeValue(forKey: connectionId) + collapsedDatabaseEnvironmentsByConnection.removeValue(forKey: connectionId) + persistFolders() + persistLinkedNodes() + persistCollapsedDatabaseEnvironments() + } + private func load() { if let data = AppStorageEnvironment.shared.defaults.data(forKey: foldersKey), let decoded = try? JSONDecoder().decode([UUID: Set].self, from: data) { @@ -64,6 +100,13 @@ internal final class FavoritesExpansionState { let decoded = try? JSONDecoder().decode([UUID: Set].self, from: data) { linkedNodesByConnection = decoded } + if let data = AppStorageEnvironment.shared.defaults.data(forKey: collapsedDatabaseEnvironmentsKey), + let decoded = try? JSONDecoder().decode( + [UUID: Set].self, + from: data + ) { + collapsedDatabaseEnvironmentsByConnection = decoded + } } private func persistFolders() { @@ -77,4 +120,10 @@ internal final class FavoritesExpansionState { AppStorageEnvironment.shared.defaults.set(data, forKey: linkedKey) } } + + private func persistCollapsedDatabaseEnvironments() { + if let data = try? JSONEncoder().encode(collapsedDatabaseEnvironmentsByConnection) { + AppStorageEnvironment.shared.defaults.set(data, forKey: collapsedDatabaseEnvironmentsKey) + } + } } diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift index f41eed3e7..aa88fb36a 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift @@ -67,6 +67,14 @@ extension DatabaseTreeOutlineCoordinator { sidebarState?.clearRecentTables(inDatabase: mainCoordinator?.browseDatabaseName) case .useAsActive(let container): useAsActive(container) + case .setFavoriteDatabase(let database, let environment): + favoriteDatabasesStorage.setFavorite( + database: database, + environment: environment, + connectionId: connectionId + ) + case .removeFavoriteDatabase(let database): + favoriteDatabasesStorage.removeFavorite(database: database, connectionId: connectionId) case .refreshContainers(let targets): refreshContainers(targets) case .copyContainerNames(let targets): diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift index 794053821..93c3a36c9 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift @@ -33,6 +33,12 @@ extension DatabaseTreeOutlineCoordinator: NSMenuDelegate { private func menuContext() -> DatabaseTreeMenuContext { let clicked = clickedNode() let clickedRef = clicked.flatMap(DatabaseTreeSelection.tableRef) + let favoriteDatabaseEnvironment: FavoriteDatabaseEnvironment? + if case .database(let metadata) = clicked?.kind { + favoriteDatabaseEnvironment = self.favoriteDatabaseEnvironment(for: metadata.name) + } else { + favoriteDatabaseEnvironment = nil + } let settings = AppSettingsManager.shared.general return DatabaseTreeMenuContext( clicked: clicked?.kind, @@ -58,6 +64,7 @@ extension DatabaseTreeOutlineCoordinator: NSMenuDelegate { schemaEntityNamePlural: PluginManager.shared.schemaEntityNamePlural(for: databaseType), objectKindTitles: objectKindTitles(), isFavorite: clickedRef.map { isFavorite($0) } ?? false, + favoriteDatabaseEnvironment: favoriteDatabaseEnvironment, showObjectIcons: settings.showObjectIcons, showObjectComments: settings.showObjectComments, rowSize: settings.sidebarRowSize, diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift index c58a527ad..4bd532fc8 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift @@ -14,6 +14,7 @@ final class DatabaseTreeOutlineCoordinator: NSObject { internal let service = DatabaseTreeMetadataService.shared private static let cellIdentifier = NSUserInterfaceItemIdentifier("DatabaseTreeCell") private let favoriteTablesStorage: FavoriteTablesStorage + internal let favoriteDatabasesStorage: FavoriteDatabasesStorage internal var connectionId = UUID() internal var databaseType: DatabaseType = .mysql @@ -53,10 +54,30 @@ final class DatabaseTreeOutlineCoordinator: NSObject { internal let schemaService = SchemaService.shared private var favoriteTables: Set = [] - private var favoritesObserver: (any NSObjectProtocol)? + private var favoriteDatabases: Set = [] + private var tableFavoritesObserver: (any NSObjectProtocol)? + private var databaseFavoritesObserver: (any NSObjectProtocol)? + + override convenience init() { + self.init( + favoriteTablesStorage: .shared, + favoriteDatabasesStorage: .shared + ) + } + + convenience init(favoriteTablesStorage: FavoriteTablesStorage) { + self.init( + favoriteTablesStorage: favoriteTablesStorage, + favoriteDatabasesStorage: .shared + ) + } - init(favoriteTablesStorage: FavoriteTablesStorage = .shared) { + init( + favoriteTablesStorage: FavoriteTablesStorage, + favoriteDatabasesStorage: FavoriteDatabasesStorage + ) { self.favoriteTablesStorage = favoriteTablesStorage + self.favoriteDatabasesStorage = favoriteDatabasesStorage super.init() } @@ -72,7 +93,7 @@ final class DatabaseTreeOutlineCoordinator: NSObject { func attach(outlineView: NSOutlineView) { self.outlineView = outlineView - favoritesObserver = NotificationCenter.default.addObserver( + tableFavoritesObserver = NotificationCenter.default.addObserver( forName: .favoriteTablesDidChange, object: nil, queue: .main ) { [weak self] _ in MainActor.assumeIsolated { @@ -81,11 +102,23 @@ final class DatabaseTreeOutlineCoordinator: NSObject { self.refreshVisibleRows() } } + databaseFavoritesObserver = NotificationCenter.default.addObserver( + forName: .favoriteDatabasesDidChange, object: nil, queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + guard let self else { return } + self.reloadFavorites() + self.refreshVisibleRows() + } + } } deinit { - if let favoritesObserver { - NotificationCenter.default.removeObserver(favoritesObserver) + if let tableFavoritesObserver { + NotificationCenter.default.removeObserver(tableFavoritesObserver) + } + if let databaseFavoritesObserver { + NotificationCenter.default.removeObserver(databaseFavoritesObserver) } } @@ -238,6 +271,7 @@ final class DatabaseTreeOutlineCoordinator: NSObject { private func reloadFavorites() { favoriteTables = favoriteTablesStorage.favorites(for: connectionId) + favoriteDatabases = favoriteDatabasesStorage.favorites(for: connectionId) } private func favoriteEntry(for ref: DatabaseTreeTableRef) -> FavoriteTablesStorage.FavoriteEntry { @@ -254,8 +288,17 @@ final class DatabaseTreeOutlineCoordinator: NSObject { } private func favoriteState(for node: DatabaseTreeNode) -> Bool { - guard let ref = DatabaseTreeSelection.tableRef(of: node) else { return false } - return isFavorite(ref) + switch node.kind { + case .database(let metadata): + return favoriteDatabaseEnvironment(for: metadata.name) != nil + default: + guard let ref = DatabaseTreeSelection.tableRef(of: node) else { return false } + return isFavorite(ref) + } + } + + internal func favoriteDatabaseEnvironment(for database: String) -> FavoriteDatabaseEnvironment? { + favoriteDatabases.first { $0.database == database }?.environment } internal func toggleFavorite(_ ref: DatabaseTreeTableRef) { diff --git a/TablePro/Views/Sidebar/DatabaseTreeRowView.swift b/TablePro/Views/Sidebar/DatabaseTreeRowView.swift index 19ab10793..37d19f3b1 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeRowView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeRowView.swift @@ -58,12 +58,7 @@ struct DatabaseTreeRowView: View { case .recentTable(let ref): tableRow(ref) case .database(let metadata): - header( - text: metadata.name, - systemImage: metadata.isSystemDatabase ? "gearshape" : "cylinder", - isActive: metadata.name == context.activeDatabase, - isSystem: metadata.isSystemDatabase - ) + databaseRow(metadata) case .schema(let database, let schema): header( text: schema, @@ -112,6 +107,28 @@ struct DatabaseTreeRowView: View { .lineLimit(1) } + private func databaseRow(_ metadata: DatabaseMetadata) -> some View { + HStack(spacing: 6) { + header( + text: metadata.name, + systemImage: metadata.isSystemDatabase ? "gearshape" : "cylinder", + isActive: metadata.name == context.activeDatabase, + isSystem: metadata.isSystemDatabase + ) + Spacer(minLength: 4) + if isFavorite { + Image(systemName: "star.fill") + .foregroundStyle(.yellow) + .accessibilityHidden(true) + } + } + .accessibilityLabel( + isFavorite + ? String(format: String(localized: "%@, Favorite"), metadata.name) + : metadata.name + ) + } + private func tableRow(_ ref: DatabaseTreeTableRef) -> some View { TableRow( table: ref.table, diff --git a/TablePro/Views/Sidebar/FavoriteDatabaseFilterBar.swift b/TablePro/Views/Sidebar/FavoriteDatabaseFilterBar.swift new file mode 100644 index 000000000..0ab8d2157 --- /dev/null +++ b/TablePro/Views/Sidebar/FavoriteDatabaseFilterBar.swift @@ -0,0 +1,30 @@ +// +// FavoriteDatabaseFilterBar.swift +// TablePro +// + +import SwiftUI + +internal struct FavoriteDatabaseFilterBar: View { + @Binding internal var selection: FavoriteDatabaseEnvironmentFilter + + internal var body: some View { + HStack(spacing: 6) { + Label(String(localized: "Environment"), systemImage: "line.3.horizontal.decrease") + .foregroundStyle(.secondary) + Spacer(minLength: 4) + Picker(String(localized: "Environment"), selection: $selection) { + ForEach(FavoriteDatabaseEnvironmentFilter.allCases, id: \.self) { filter in + Text(filter.title) + .tag(filter) + } + } + .labelsHidden() + .pickerStyle(.menu) + .controlSize(.small) + } + .font(.caption) + .padding(.horizontal, 10) + .padding(.vertical, 5) + } +} diff --git a/TablePro/Views/Sidebar/FavoritesExpansion.swift b/TablePro/Views/Sidebar/FavoritesExpansion.swift index 088e1785a..5ab064b2f 100644 --- a/TablePro/Views/Sidebar/FavoritesExpansion.swift +++ b/TablePro/Views/Sidebar/FavoritesExpansion.swift @@ -30,4 +30,23 @@ internal enum FavoritesExpansion { break } } + + internal static func isDatabaseEnvironmentExpanded( + _ environment: FavoriteDatabaseEnvironment, + connectionId: UUID + ) -> Bool { + FavoritesExpansionState.shared.isDatabaseEnvironmentExpanded(environment, for: connectionId) + } + + internal static func setDatabaseEnvironmentExpanded( + _ environment: FavoriteDatabaseEnvironment, + expanded: Bool, + connectionId: UUID + ) { + FavoritesExpansionState.shared.setDatabaseEnvironmentExpanded( + environment, + expanded: expanded, + for: connectionId + ) + } } diff --git a/TablePro/Views/Sidebar/FavoritesOutlineCoordinator.swift b/TablePro/Views/Sidebar/FavoritesOutlineCoordinator.swift index 87d74fb1b..250756b9b 100644 --- a/TablePro/Views/Sidebar/FavoritesOutlineCoordinator.swift +++ b/TablePro/Views/Sidebar/FavoritesOutlineCoordinator.swift @@ -54,7 +54,11 @@ internal final class FavoritesOutlineCoordinator: NSObject, NSOutline /// outline reloads only when the set of rows or their nesting changed. Depth is part of the /// fingerprint because moving a favorite into a folder can leave the pre-order id list identical. private static func fingerprint(of input: FavoritesOutlineInput) -> String { - var parts: [String] = [input.activeDatabase ?? ""] + var parts: [String] = [input.activeDatabase ?? "", input.isFilteringDatabases ? "filtering" : ""] + parts += input.databaseGroups.flatMap { group in + ["environment|\(group.environment.rawValue)"] + + group.entries.map { "\(group.environment.rawValue)|\($0.id)" } + } parts += input.tables.map(\.id) parts += input.queryNodes.flatMap { Self.identifiers(of: $0, depth: 0) } parts += input.teamQueries.map(\.id) @@ -121,12 +125,33 @@ internal final class FavoritesOutlineCoordinator: NSObject, NSOutline private func build(children parent: FavoritesOutlineNode?) -> [FavoritesOutlineNode] { guard let parent else { return rootNodes() } - guard case .query(let favoriteNode) = parent.kind, let kids = favoriteNode.children else { return [] } - return kids.map { node(id: $0.id, kind: .query($0)) } + switch parent.kind { + case .databaseEnvironment(let group): + return group.entries.map { entry in + node(id: FavoritesOutlineNode.databaseId(entry), kind: .database(entry)) + } + case .query(let favoriteNode): + guard let kids = favoriteNode.children else { return [] } + return kids.map { node(id: $0.id, kind: .query($0)) } + case .header, .database, .table, .teamQuery: + return [] + } } private func rootNodes() -> [FavoritesOutlineNode] { var nodes: [FavoritesOutlineNode] = [] + if !owner.input.databaseGroups.isEmpty { + nodes.append(node( + id: FavoritesOutlineNode.databasesHeaderId, + kind: .header(owner.input.databaseEntityNamePlural) + )) + nodes += owner.input.databaseGroups.map { group in + node( + id: FavoritesOutlineNode.databaseEnvironmentId(group.environment), + kind: .databaseEnvironment(group) + ) + } + } if !owner.input.tables.isEmpty { nodes.append(node(id: FavoritesOutlineNode.tablesHeaderId, kind: .header(String(localized: "Tables")))) nodes += owner.input.tables.map { table in @@ -163,8 +188,20 @@ internal final class FavoritesOutlineCoordinator: NSObject, NSOutline private func applyExpansion(to nodes: [FavoritesOutlineNode], in outlineView: NSOutlineView) { for node in nodes where node.isExpandable { - guard case .query(let favoriteNode) = node.kind else { continue } - if FavoritesExpansion.isExpanded(favoriteNode, connectionId: owner.input.connectionId) { + let shouldExpand: Bool + switch node.kind { + case .databaseEnvironment(let group): + shouldExpand = owner.input.isFilteringDatabases || FavoritesExpansion + .isDatabaseEnvironmentExpanded(group.environment, connectionId: owner.input.connectionId) + case .query(let favoriteNode): + shouldExpand = FavoritesExpansion.isExpanded( + favoriteNode, + connectionId: owner.input.connectionId + ) + case .header, .database, .table, .teamQuery: + shouldExpand = false + } + if shouldExpand { outlineView.expandItem(node) applyExpansion(to: children(of: node), in: outlineView) } else { @@ -183,9 +220,24 @@ internal final class FavoritesOutlineCoordinator: NSObject, NSOutline private func recordExpansion(from notification: Notification, expanded: Bool) { guard !isApplyingExpansion, - let node = notification.userInfo?["NSObject"] as? FavoritesOutlineNode, - case .query(let favoriteNode) = node.kind else { return } - FavoritesExpansion.setExpanded(favoriteNode, expanded: expanded, connectionId: owner.input.connectionId) + let node = notification.userInfo?["NSObject"] as? FavoritesOutlineNode else { return } + switch node.kind { + case .databaseEnvironment(let group): + guard !owner.input.isFilteringDatabases else { return } + FavoritesExpansion.setDatabaseEnvironmentExpanded( + group.environment, + expanded: expanded, + connectionId: owner.input.connectionId + ) + case .query(let favoriteNode): + FavoritesExpansion.setExpanded( + favoriteNode, + expanded: expanded, + connectionId: owner.input.connectionId + ) + case .header, .database, .table, .teamQuery: + break + } } // MARK: - Selection @@ -270,7 +322,9 @@ internal final class FavoritesOutlineCoordinator: NSObject, NSOutline let context = FavoritesMenuContext( clicked: clicked?.kind, allFolders: owner.input.allFolders, - teamLibraryAvailable: owner.input.teamLibraryAvailable + teamLibraryAvailable: owner.input.teamLibraryAvailable, + databaseEntityName: owner.input.databaseEntityName, + activeDatabase: owner.input.activeDatabase ) SidebarMenuBuilder.fill( menu, diff --git a/TablePro/Views/Sidebar/FavoritesOutlineNode.swift b/TablePro/Views/Sidebar/FavoritesOutlineNode.swift index bad7df27c..08c39982a 100644 --- a/TablePro/Views/Sidebar/FavoritesOutlineNode.swift +++ b/TablePro/Views/Sidebar/FavoritesOutlineNode.swift @@ -13,6 +13,8 @@ import Foundation internal final class FavoritesOutlineNode: SidebarOutlineNode { internal enum Kind { case header(String) + case databaseEnvironment(FavoriteDatabaseGroup) + case database(FavoriteDatabaseEntry) case table(TableInfo) case query(FavoriteNode) case teamQuery(id: String, name: String, publishedBy: String?) @@ -27,8 +29,14 @@ internal final class FavoritesOutlineNode: SidebarOutlineNode { } internal var isExpandable: Bool { - guard case .query(let node) = kind else { return false } - return node.isFolder + switch kind { + case .databaseEnvironment: + return true + case .query(let node): + return node.isFolder + case .header, .database, .table, .teamQuery: + return false + } } /// Tables, Queries and Team Library are buckets rather than objects, so AppKit draws them as @@ -39,6 +47,7 @@ internal final class FavoritesOutlineNode: SidebarOutlineNode { } internal static let tablesHeaderId = "favorites\u{1}header\u{1}tables" + internal static let databasesHeaderId = "favorites\u{1}header\u{1}databases" internal static let queriesHeaderId = "favorites\u{1}header\u{1}queries" internal static let teamHeaderId = "favorites\u{1}header\u{1}team" @@ -48,5 +57,13 @@ internal final class FavoritesOutlineNode: SidebarOutlineNode { ["favtable", database ?? "", schema ?? "", name].joined(separator: "\u{1}") } + internal static func databaseEnvironmentId(_ environment: FavoriteDatabaseEnvironment) -> String { + "favdatabaseenv\u{1}\(environment.rawValue)" + } + + internal static func databaseId(_ entry: FavoriteDatabaseEntry) -> String { + "favdatabase\u{1}\(entry.id)" + } + internal static func teamQueryId(_ clientId: String) -> String { "favteam\u{1}\(clientId)" } } diff --git a/TablePro/Views/Sidebar/FavoritesOutlineSelection.swift b/TablePro/Views/Sidebar/FavoritesOutlineSelection.swift index aae0d10e4..722aa2154 100644 --- a/TablePro/Views/Sidebar/FavoritesOutlineSelection.swift +++ b/TablePro/Views/Sidebar/FavoritesOutlineSelection.swift @@ -21,6 +21,10 @@ internal enum FavoritesOutlineSelection { switch kind { case .header: return nil + case .databaseEnvironment(let group): + return .node(id: FavoritesOutlineNode.databaseEnvironmentId(group.environment)) + case .database(let entry): + return .node(id: FavoritesOutlineNode.databaseId(entry)) case .table(let table): return .table(database: database, schema: table.schema, name: table.name) case .query(let node): @@ -45,6 +49,10 @@ internal enum FavoritesOutlineSelection { switch kind { case .header: return nil + case .databaseEnvironment(let group): + return group.environment.title + case .database(let entry): + return entry.database case .table(let table): return table.name case .teamQuery(_, let name, _): diff --git a/TablePro/Views/Sidebar/FavoritesOutlineView.swift b/TablePro/Views/Sidebar/FavoritesOutlineView.swift index d9eabef15..c6a6eae4c 100644 --- a/TablePro/Views/Sidebar/FavoritesOutlineView.swift +++ b/TablePro/Views/Sidebar/FavoritesOutlineView.swift @@ -11,6 +11,10 @@ import SwiftUI internal struct FavoritesOutlineInput { internal let connectionId: UUID internal let activeDatabase: String? + internal let databaseGroups: [FavoriteDatabaseGroup] + internal let databaseEntityName: String + internal let databaseEntityNamePlural: String + internal let isFilteringDatabases: Bool internal let tables: [TableInfo] internal let queryNodes: [FavoriteNode] internal let teamQueries: [FavoritesOutlineTeamQuery] diff --git a/TablePro/Views/Sidebar/FavoritesTabView.swift b/TablePro/Views/Sidebar/FavoritesTabView.swift index 69402febc..f58d10b0a 100644 --- a/TablePro/Views/Sidebar/FavoritesTabView.swift +++ b/TablePro/Views/Sidebar/FavoritesTabView.swift @@ -6,6 +6,7 @@ internal struct FavoritesTabView: View { @State private var viewModel: FavoritesSidebarViewModel @State private var favoriteTables: [FavoriteTablesStorage.FavoriteEntry] = [] + @State private var favoriteDatabases: Set = [] @State private var folderToDelete: SQLFavoriteFolder? @State private var showDeleteFolderAlert = false @State private var linkedFileToTrash: LinkedSQLFavorite? @@ -14,6 +15,7 @@ internal struct FavoritesTabView: View { @State private var linkedFolderToRemove: LinkedSQLFolder? @State private var showRemoveLinkedFolderAlert = false let connectionId: UUID + let databaseType: DatabaseType @Bindable private var sharedSidebarState: SharedSidebarState let tables: [TableInfo] private var coordinator: MainContentCoordinator? @@ -24,6 +26,22 @@ internal struct FavoritesTabView: View { return name.isEmpty ? nil : name } + private var databaseGroups: [FavoriteDatabaseGroup] { + FavoriteDatabaseGrouping.groups( + entries: favoriteDatabases, + searchText: searchText, + filter: sharedSidebarState.favoriteDatabaseEnvironmentFilter + ) + } + + private var databaseEntityName: String { + PluginManager.shared.containerEntityName(for: databaseType) + } + + private var databaseEntityNamePlural: String { + PluginManager.shared.containerEntityNamePlural(for: databaseType) + } + private var availableFavoriteTables: [TableInfo] { let database = activeDatabase let tablesByKey = Dictionary( @@ -40,8 +58,15 @@ internal struct FavoritesTabView: View { "\(schema ?? "")\u{1}\(name)" } - init(connectionId: UUID, sharedSidebarState: SharedSidebarState, tables: [TableInfo], coordinator: MainContentCoordinator?) { + init( + connectionId: UUID, + databaseType: DatabaseType, + sharedSidebarState: SharedSidebarState, + tables: [TableInfo], + coordinator: MainContentCoordinator? + ) { self.connectionId = connectionId + self.databaseType = databaseType self.sharedSidebarState = sharedSidebarState self.tables = tables _viewModel = State(wrappedValue: FavoritesSidebarViewModel(connectionId: connectionId)) @@ -50,31 +75,50 @@ internal struct FavoritesTabView: View { var body: some View { VStack(spacing: 0) { + if !favoriteDatabases.isEmpty { + FavoriteDatabaseFilterBar(selection: $sharedSidebarState.favoriteDatabaseEnvironmentFilter) + Divider() + } Group { let items = viewModel.filteredNodes(searchText: searchText) + let groups = databaseGroups let filteredTables = searchText.isEmpty ? availableFavoriteTables : availableFavoriteTables.filter { $0.name.localizedCaseInsensitiveContains(searchText) } - if !viewModel.isInitialLoadComplete && viewModel.nodes.isEmpty && filteredTables.isEmpty { + if !viewModel.isInitialLoadComplete + && viewModel.nodes.isEmpty + && filteredTables.isEmpty + && favoriteDatabases.isEmpty { ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if viewModel.nodes.isEmpty && filteredTables.isEmpty && teamLibraryQueries.isEmpty && searchText.isEmpty { + } else if viewModel.nodes.isEmpty + && filteredTables.isEmpty + && teamLibraryQueries.isEmpty + && favoriteDatabases.isEmpty + && searchText.isEmpty { emptyState - } else if items.isEmpty && filteredTables.isEmpty && teamLibraryQueries.isEmpty { + } else if items.isEmpty + && groups.isEmpty + && filteredTables.isEmpty + && teamLibraryQueries.isEmpty { noMatchState } else { - favoritesList(items, filteredTables: filteredTables) + favoritesList(items, databaseGroups: groups, filteredTables: filteredTables) } } } .onAppear { viewModel.startWatchingLinkedFolders() favoriteTables = viewModel.favoriteTables(for: connectionId) + favoriteDatabases = FavoriteDatabasesStorage.shared.favorites(for: connectionId) } .onReceive(NotificationCenter.default.publisher(for: .favoriteTablesDidChange)) { _ in favoriteTables = viewModel.favoriteTables(for: connectionId) } + .onReceive(NotificationCenter.default.publisher(for: .favoriteDatabasesDidChange)) { _ in + favoriteDatabases = FavoriteDatabasesStorage.shared.favorites(for: connectionId) + } .sheet(item: $viewModel.editDialogItem) { item in FavoriteEditDialog( connectionId: connectionId, @@ -220,12 +264,17 @@ internal struct FavoritesTabView: View { /// container changed. private func favoritesList( _ items: [FavoriteNode], + databaseGroups: [FavoriteDatabaseGroup], filteredTables: [TableInfo] ) -> some View { FavoritesOutlineView( input: FavoritesOutlineInput( connectionId: connectionId, activeDatabase: activeDatabase, + databaseGroups: databaseGroups, + databaseEntityName: databaseEntityName, + databaseEntityNamePlural: databaseEntityNamePlural, + isFilteringDatabases: !searchText.isEmpty, tables: filteredTables, queryNodes: items, teamQueries: teamLibraryQueries.map { @@ -278,6 +327,10 @@ internal struct FavoritesTabView: View { Text(title) .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) + case .databaseEnvironment(let group): + databaseEnvironmentRow(group) + case .database(let entry): + favoriteDatabaseRow(entry) case .table(let table): favoriteTableRow(table: table) case .query(let favoriteNode): @@ -287,6 +340,32 @@ internal struct FavoritesTabView: View { } } + private func databaseEnvironmentRow(_ group: FavoriteDatabaseGroup) -> some View { + Label { + HStack(spacing: 6) { + Text(group.environment.title) + .lineLimit(1) + Text(group.entries.count, format: .number) + .font(.caption) + .foregroundStyle(.secondary) + } + } icon: { + Image(systemName: group.environment.iconName) + } + .sidebarRowIcon(visible: AppSettingsManager.shared.general.showObjectIcons) + } + + private func favoriteDatabaseRow(_ entry: FavoriteDatabaseEntry) -> some View { + Label(entry.database, systemImage: "cylinder") + .sidebarRowIcon(visible: AppSettingsManager.shared.general.showObjectIcons) + .lineLimit(1) + .accessibilityLabel(String( + format: String(localized: "Favorite %@, %@"), + databaseEntityName, + entry.database + )) + } + @ViewBuilder private func favoriteQueryRow(_ node: FavoriteNode) -> some View { switch node.content { @@ -359,6 +438,10 @@ internal struct FavoritesTabView: View { switch kind { case .header: break + case .databaseEnvironment: + break + case .database(let entry): + useDatabase(entry) case .table(let table): coordinator?.openTableTab(table, activateGridFocus: true) case .query(let node): @@ -379,8 +462,13 @@ internal struct FavoritesTabView: View { private func deleteNode(_ kind: FavoritesOutlineNode.Kind) { switch kind { - case .header, .teamQuery: + case .header, .databaseEnvironment, .teamQuery: break + case .database(let entry): + FavoriteDatabasesStorage.shared.removeFavorite( + database: entry.database, + connectionId: connectionId + ) case .table(let table): FavoriteTablesStorage.shared.removeFavorite( name: table.name, schema: table.schema, database: activeDatabase, connectionId: connectionId @@ -404,6 +492,19 @@ internal struct FavoritesTabView: View { /// state this view already owns, so the alert stays where the rest of the presentation is. private func perform(_ command: FavoritesMenuCommand) { switch command { + case .useDatabase(let entry): + useDatabase(entry) + case .setDatabaseEnvironment(let entry, let environment): + FavoriteDatabasesStorage.shared.setFavorite( + database: entry.database, + environment: environment, + connectionId: connectionId + ) + case .removeDatabaseFavorite(let entry): + FavoriteDatabasesStorage.shared.removeFavorite( + database: entry.database, + connectionId: connectionId + ) case .openTable(let table): coordinator?.openTableTab(table, activateGridFocus: true) case .showERDiagram: @@ -464,6 +565,11 @@ internal struct FavoritesTabView: View { } } + private func useDatabase(_ entry: FavoriteDatabaseEntry) { + guard entry.database != activeDatabase else { return } + Task { await coordinator?.switchDatabase(to: entry.database) } + } + // MARK: - Empty States /// An empty list has no row to right-click, so the commands the background menu carries have to diff --git a/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift b/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift index 373976c4d..67e13e478 100644 --- a/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift +++ b/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift @@ -28,6 +28,7 @@ internal struct DatabaseTreeMenuContext { internal let schemaEntityNamePlural: String internal let objectKindTitles: [SidebarObjectKind: String] internal let isFavorite: Bool + internal let favoriteDatabaseEnvironment: FavoriteDatabaseEnvironment? internal let showObjectIcons: Bool internal let showObjectComments: Bool internal let rowSize: SidebarRowSizePreference @@ -216,7 +217,16 @@ internal enum DatabaseTreeMenuSpec { items.append(.command(String(localized: "Refresh"), .refreshContainers(targets))) items.append(.command(copyNamesTitle(count: targets.count), .copyContainerNames(targets))) + if targets.count == 1, clicked.kind == .database { + items.append(.separator) + items += favoriteDatabaseItems( + database: clicked.database, + currentEnvironment: context.favoriteDatabaseEnvironment + ) + } + if ExportPreselection.canPreselect(containers: targets, activeDatabase: context.activeDatabase) { + items.append(.separator) items.append(.command(String(localized: "Export…"), .exportContainers(targets))) } guard !droppable.isEmpty else { return items } @@ -225,6 +235,26 @@ internal enum DatabaseTreeMenuSpec { return items } + private static func favoriteDatabaseItems( + database: String, + currentEnvironment: FavoriteDatabaseEnvironment? + ) -> [DatabaseTreeMenuItem] { + let environmentItems: [DatabaseTreeMenuItem] = FavoriteDatabaseEnvironment.allCases.map { environment in + .command(SidebarMenuEntry( + title: environment.menuTitle, + command: .setFavoriteDatabase(database: database, environment: environment), + isOn: currentEnvironment == environment + )) + } + guard currentEnvironment != nil else { + return [.submenu(title: String(localized: "Add to Favorites"), items: environmentItems)] + } + return [ + .submenu(title: String(localized: "Environment"), items: environmentItems), + .destructive(String(localized: "Remove from Favorites"), .removeFavoriteDatabase(database)) + ] + } + private static func isActive(_ container: DatabaseContainerRef, context: DatabaseTreeMenuContext) -> Bool { switch container.kind { case .database: diff --git a/TablePro/Views/Sidebar/Menu/FavoritesMenuCommand.swift b/TablePro/Views/Sidebar/Menu/FavoritesMenuCommand.swift index 6ee58aa98..1822cce2a 100644 --- a/TablePro/Views/Sidebar/Menu/FavoritesMenuCommand.swift +++ b/TablePro/Views/Sidebar/Menu/FavoritesMenuCommand.swift @@ -11,6 +11,10 @@ import TableProPluginKit /// Several of these end in a confirmation the view owns, so the command names the intent and the /// view decides how to ask. Keeping that split is what lets the whole menu be a pure function. internal enum FavoritesMenuCommand: Equatable { + case useDatabase(FavoriteDatabaseEntry) + case setDatabaseEnvironment(FavoriteDatabaseEntry, FavoriteDatabaseEnvironment) + case removeDatabaseFavorite(FavoriteDatabaseEntry) + case openTable(TableInfo) case showERDiagram case removeTableFavorite(TableInfo) diff --git a/TablePro/Views/Sidebar/Menu/FavoritesMenuSpec.swift b/TablePro/Views/Sidebar/Menu/FavoritesMenuSpec.swift index 9b0e70627..3234d31d3 100644 --- a/TablePro/Views/Sidebar/Menu/FavoritesMenuSpec.swift +++ b/TablePro/Views/Sidebar/Menu/FavoritesMenuSpec.swift @@ -10,15 +10,21 @@ internal struct FavoritesMenuContext { internal let clicked: FavoritesOutlineNode.Kind? internal let allFolders: [SQLFavoriteFolder] internal let teamLibraryAvailable: Bool + internal let databaseEntityName: String + internal let activeDatabase: String? internal init( clicked: FavoritesOutlineNode.Kind?, allFolders: [SQLFavoriteFolder] = [], - teamLibraryAvailable: Bool = false + teamLibraryAvailable: Bool = false, + databaseEntityName: String = "Database", + activeDatabase: String? = nil ) { self.clicked = clicked self.allFolders = allFolders self.teamLibraryAvailable = teamLibraryAvailable + self.databaseEntityName = databaseEntityName + self.activeDatabase = activeDatabase } } @@ -30,6 +36,10 @@ internal enum FavoritesMenuSpec { private static func rawItems(for context: FavoritesMenuContext) -> [FavoritesMenuItem] { guard let clicked = context.clicked else { return backgroundItems(context) } switch clicked { + case .databaseEnvironment: + return backgroundItems(context) + case .database(let entry): + return databaseItems(entry, context: context) case .table(let table): return tableItems(table) case .query(let node): @@ -39,6 +49,38 @@ internal enum FavoritesMenuSpec { } } + private static func databaseItems( + _ entry: FavoriteDatabaseEntry, + context: FavoritesMenuContext + ) -> [FavoritesMenuItem] { + var items: [FavoritesMenuItem] = [] + if entry.database != context.activeDatabase { + items.append(.command( + String( + format: String(localized: "Use as Active %@"), + context.databaseEntityName + ), + .useDatabase(entry) + )) + } + items.append(.submenu( + title: String(localized: "Environment"), + items: FavoriteDatabaseEnvironment.allCases.map { environment in + .command(SidebarMenuEntry( + title: environment.menuTitle, + command: .setDatabaseEnvironment(entry, environment), + isOn: entry.environment == environment + )) + } + )) + items.append(.separator) + items.append(.destructive( + String(localized: "Remove from Favorites"), + .removeDatabaseFavorite(entry) + )) + return items + } + private static func tableItems(_ table: TableInfo) -> [FavoritesMenuItem] { [ .command(String(localized: "Open Table"), .openTable(table)), diff --git a/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift b/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift index 3ab656d04..d5eddd3d1 100644 --- a/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift +++ b/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift @@ -35,6 +35,8 @@ internal enum SidebarMenuCommand: Equatable { case removeRecent(DatabaseTreeTableRef) case clearRecents case useAsActive(DatabaseContainerRef) + case setFavoriteDatabase(database: String, environment: FavoriteDatabaseEnvironment) + case removeFavoriteDatabase(String) case refreshContainers([DatabaseContainerRef]) case copyContainerNames([DatabaseContainerRef]) case exportContainers([DatabaseContainerRef]) diff --git a/TablePro/Views/Sidebar/SidebarPersistenceKey.swift b/TablePro/Views/Sidebar/SidebarPersistenceKey.swift index feec084d6..5ea0ed005 100644 --- a/TablePro/Views/Sidebar/SidebarPersistenceKey.swift +++ b/TablePro/Views/Sidebar/SidebarPersistenceKey.swift @@ -24,6 +24,10 @@ enum SidebarPersistenceKey { "sidebar.selectedFavoriteNodeId.\(connectionId.uuidString)" } + static func favoriteDatabaseEnvironmentFilter(connectionId: UUID) -> String { + "sidebar.favoriteDatabaseEnvironmentFilter.\(connectionId.uuidString)" + } + static let defaultLayout = "sidebar.defaultLayout" static func layout(connectionId: UUID) -> String { diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index ba7ebb154..d14b3f247 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -95,6 +95,7 @@ struct SidebarView: View { if let coordinator { FavoritesTabView( connectionId: connectionId, + databaseType: viewModel.databaseType, sharedSidebarState: sidebarState, tables: tables, coordinator: coordinator diff --git a/TableProTests/Core/Storage/FavoriteDatabasesStorageTests.swift b/TableProTests/Core/Storage/FavoriteDatabasesStorageTests.swift new file mode 100644 index 000000000..fa4dcfd1f --- /dev/null +++ b/TableProTests/Core/Storage/FavoriteDatabasesStorageTests.swift @@ -0,0 +1,116 @@ +// +// FavoriteDatabasesStorageTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@MainActor +@Suite("FavoriteDatabasesStorage") +struct FavoriteDatabasesStorageTests { + private func makeStorage() throws -> (FavoriteDatabasesStorage, UserDefaults) { + let suite = "FavoriteDatabasesStorageTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + return (FavoriteDatabasesStorage(defaults: defaults), defaults) + } + + @Test("Favorite identity includes the connection") + func favoritesAreConnectionScoped() throws { + let (storage, _) = try makeStorage() + let first = UUID() + let second = UUID() + + storage.setFavorite(database: "app", environment: .development, connectionId: first) + storage.setFavorite(database: "app", environment: .production, connectionId: second) + + #expect(storage.favorites(for: first).first?.environment == .development) + #expect(storage.favorites(for: second).first?.environment == .production) + } + + @Test("Changing an environment replaces the favorite instead of duplicating it") + func environmentUpdateReplacesEntry() throws { + let (storage, _) = try makeStorage() + let connectionId = UUID() + + storage.setFavorite(database: "orders", environment: .development, connectionId: connectionId) + storage.setFavorite(database: "orders", environment: .testing, connectionId: connectionId) + + let entries = storage.favorites(for: connectionId) + #expect(entries.count == 1) + #expect(entries.first?.environment == .testing) + } + + @Test("Removing one database preserves the connection's other favorites") + func removePreservesOtherEntries() throws { + let (storage, _) = try makeStorage() + let connectionId = UUID() + storage.setFavorite(database: "app", environment: .development, connectionId: connectionId) + storage.setFavorite(database: "audit", environment: .testing, connectionId: connectionId) + + storage.removeFavorite(database: "app", connectionId: connectionId) + + #expect(storage.favorites(for: connectionId).map(\.database) == ["audit"]) + } + + @Test("An unknown stored environment falls back to Unassigned") + func unknownEnvironmentFallsBack() throws { + let (storage, defaults) = try makeStorage() + let connectionId = UUID() + let json = """ + [{"connectionId":"\(connectionId.uuidString)","database":"future","environment":"staging"}] + """ + defaults.set( + Data(json.utf8), + forKey: "com.TablePro.favoriteDatabases.\(connectionId.uuidString)" + ) + + #expect( + storage.favorites(for: connectionId).first?.environment + == FavoriteDatabaseEnvironment.none + ) + } + + @Test("Malformed storage is contained to the affected connection") + func malformedStorageReturnsEmpty() throws { + let (storage, defaults) = try makeStorage() + let connectionId = UUID() + defaults.set( + Data("not-json".utf8), + forKey: "com.TablePro.favoriteDatabases.\(connectionId.uuidString)" + ) + + #expect(storage.favorites(for: connectionId).isEmpty) + } + + @Test("Stored entries cannot cross connection boundaries") + func ignoresEntriesFromAnotherConnection() throws { + let (storage, defaults) = try makeStorage() + let requestedConnectionId = UUID() + let foreignConnectionId = UUID() + let json = """ + [{"connectionId":"\(foreignConnectionId.uuidString)","database":"private","environment":"production"}] + """ + defaults.set( + Data(json.utf8), + forKey: "com.TablePro.favoriteDatabases.\(requestedConnectionId.uuidString)" + ) + + #expect(storage.favorites(for: requestedConnectionId).isEmpty) + } + + @Test("Deleting a connection removes its persisted favorites") + func removeConnectionFavorites() throws { + let (storage, defaults) = try makeStorage() + let connectionId = UUID() + storage.setFavorite(database: "app", environment: .production, connectionId: connectionId) + + storage.removeFavorites(for: connectionId) + + #expect(storage.favorites(for: connectionId).isEmpty) + #expect(defaults.object(forKey: "com.TablePro.favoriteDatabases.\(connectionId.uuidString)") == nil) + } +} diff --git a/TableProTests/Models/FavoriteDatabaseGroupingTests.swift b/TableProTests/Models/FavoriteDatabaseGroupingTests.swift new file mode 100644 index 000000000..99a9a58be --- /dev/null +++ b/TableProTests/Models/FavoriteDatabaseGroupingTests.swift @@ -0,0 +1,97 @@ +// +// FavoriteDatabaseGroupingTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("Favorite database grouping") +struct FavoriteDatabaseGroupingTests { + private let connectionId = UUID() + + private func entry(_ database: String, _ environment: FavoriteDatabaseEnvironment) -> FavoriteDatabaseEntry { + FavoriteDatabaseEntry( + connectionId: connectionId, + database: database, + environment: environment + ) + } + + @Test("Groups use environment order and database names sort naturally") + func deterministicGrouping() { + let groups = FavoriteDatabaseGrouping.groups( + entries: [ + entry("prod", .production), + entry("dev10", .development), + entry("dev2", .development), + entry("misc", .none), + entry("test", .testing) + ], + searchText: "", + filter: .all + ) + + #expect(groups.map(\.environment) == [.development, .testing, .production, .none]) + #expect(groups.first?.entries.map(\.database) == ["dev2", "dev10"]) + } + + @Test("Environment filter keeps only its group") + func filtersByEnvironment() { + let entries: Set = [ + entry("dev", .development), + entry("misc", .none), + entry("prod", .production) + ] + let groups = FavoriteDatabaseGrouping.groups( + entries: entries, + searchText: "", + filter: .production + ) + let unassigned = FavoriteDatabaseGrouping.groups( + entries: entries, + searchText: "", + filter: .unassigned + ) + + #expect(groups.count == 1) + #expect(groups.first?.entries.map(\.database) == ["prod"]) + #expect(unassigned.count == 1) + #expect(unassigned.first?.entries.map(\.database) == ["misc"]) + } + + @Test("Search matches a database or its environment title") + func searchMatchesNamesAndEnvironment() { + let entries: Set = [ + entry("billing", .development), + entry("warehouse", .production) + ] + + let nameMatch = FavoriteDatabaseGrouping.groups( + entries: entries, + searchText: "bill", + filter: .all + ) + let environmentMatch = FavoriteDatabaseGrouping.groups( + entries: entries, + searchText: "Production", + filter: .all + ) + + #expect(nameMatch.flatMap(\.entries).map(\.database) == ["billing"]) + #expect(environmentMatch.flatMap(\.entries).map(\.database) == ["warehouse"]) + } + + @Test("No matches produce no empty groups") + func hidesEmptyGroups() { + let groups = FavoriteDatabaseGrouping.groups( + entries: [entry("app", .development)], + searchText: "missing", + filter: .all + ) + + #expect(groups.isEmpty) + } +} diff --git a/TableProTests/Models/SharedSidebarStateTests.swift b/TableProTests/Models/SharedSidebarStateTests.swift index 87439e364..8f75b4343 100644 --- a/TableProTests/Models/SharedSidebarStateTests.swift +++ b/TableProTests/Models/SharedSidebarStateTests.swift @@ -8,13 +8,12 @@ // import Foundation +@testable import TablePro import TableProPluginKit import Testing -@testable import TablePro @Suite("SharedSidebarState") struct SharedSidebarStateTests { - // MARK: - Registry @Test("forConnection returns same instance for same UUID") @@ -93,6 +92,21 @@ struct SharedSidebarStateTests { SharedSidebarState.removeConnection(id) } + @Test("favorite database environment filter persists for its connection") + @MainActor + func favoriteDatabaseEnvironmentFilterPersists() { + let id = UUID() + let first = SharedSidebarState.forConnection(id) + first.favoriteDatabaseEnvironmentFilter = .testing + SharedSidebarState.removeConnection(id) + + let restored = SharedSidebarState.forConnection(id) + #expect(restored.favoriteDatabaseEnvironmentFilter == .testing) + + restored.favoriteDatabaseEnvironmentFilter = .all + SharedSidebarState.removeConnection(id) + } + @Test("filter text is independent across different connections") @MainActor func filterTextIndependentAcrossConnections() { diff --git a/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift b/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift index 76414f764..614e17f82 100644 --- a/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift +++ b/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift @@ -25,6 +25,7 @@ struct DatabaseTreeMenuSpecTests { selectedContainers: [DatabaseContainerRef] = [], isReadOnly: Bool = false, isFavorite: Bool = false, + favoriteDatabaseEnvironment: FavoriteDatabaseEnvironment? = nil, activeDatabase: String? = "app", activeSchema: String? = "public", canFilterDatabases: Bool = false, @@ -54,6 +55,7 @@ struct DatabaseTreeMenuSpecTests { schemaEntityNamePlural: "Schemas", objectKindTitles: [.table: "Tables"], isFavorite: isFavorite, + favoriteDatabaseEnvironment: favoriteDatabaseEnvironment, showObjectIcons: true, showObjectComments: false, rowSize: .matchSystem, @@ -264,6 +266,33 @@ struct DatabaseTreeMenuSpecTests { }) } + @Test("An unfavorited database offers every environment under Add to Favorites") + func databaseCanBeFavoritedWithEnvironment() { + let database = DatabaseMetadata.minimal(name: "analytics", isSystem: false) + let items = DatabaseTreeMenuSpec.items(for: context(clicked: .database(database))) + let issued = commands(items) + + #expect(titles(items).contains(String(localized: "Add to Favorites"))) + for environment in FavoriteDatabaseEnvironment.allCases { + #expect(issued.contains(.setFavoriteDatabase(database: "analytics", environment: environment))) + } + #expect(!issued.contains(.removeFavoriteDatabase("analytics"))) + } + + @Test("A favorite database can change environment or be removed") + func favoriteDatabaseMenuReflectsState() { + let database = DatabaseMetadata.minimal(name: "analytics", isSystem: false) + let items = DatabaseTreeMenuSpec.items(for: context( + clicked: .database(database), + favoriteDatabaseEnvironment: .production + )) + let issued = commands(items) + + #expect(titles(items).contains(String(localized: "Environment"))) + #expect(issued.contains(.removeFavoriteDatabase("analytics"))) + #expect(issued.contains(.setFavoriteDatabase(database: "analytics", environment: .development))) + } + // MARK: - Shape @Test("A menu never opens or closes on a separator, and never doubles one") diff --git a/TableProTests/Views/Sidebar/FavoritesMenuSpecTests.swift b/TableProTests/Views/Sidebar/FavoritesMenuSpecTests.swift index f1475a733..8fc17d2ce 100644 --- a/TableProTests/Views/Sidebar/FavoritesMenuSpecTests.swift +++ b/TableProTests/Views/Sidebar/FavoritesMenuSpecTests.swift @@ -41,6 +41,10 @@ struct FavoritesMenuSpecTests { TableInfo(name: "orders", type: .table, rowCount: nil, schema: "public") } + private func database() -> FavoriteDatabaseEntry { + FavoriteDatabaseEntry(connectionId: UUID(), database: "analytics", environment: .development) + } + private func moveTargets(_ issued: [FavoritesMenuCommand]) -> [UUID?] { issued.compactMap { command in guard case .moveFavorite(_, let target) = command else { return nil } @@ -57,6 +61,8 @@ struct FavoritesMenuSpecTests { let kinds: [FavoritesOutlineNode.Kind?] = [ nil, .header("Queries"), + .databaseEnvironment(FavoriteDatabaseGroup(environment: .development, entries: [database()])), + .database(database()), .table(table()), .teamQuery(id: "1", name: "Shared", publishedBy: "Sam"), .query(.favorite(favorite())), @@ -76,6 +82,7 @@ struct FavoritesMenuSpecTests { func separatorsAreCollapsed() { let kinds: [FavoritesOutlineNode.Kind?] = [ nil, + .database(database()), .table(table()), .query(.favorite(favorite())), .query(.folder(SQLFavoriteFolder(name: "Reports"), children: [])) @@ -111,6 +118,32 @@ struct FavoritesMenuSpecTests { #expect(!without.contains(.publishSavedQueriesToTeam)) } + @Test("A database favorite can switch, change environment, or be removed") + func databaseFavoriteCommands() { + let entry = database() + let issued = commands(FavoritesMenuSpec.items(for: FavoritesMenuContext( + clicked: .database(entry), + databaseEntityName: "Database", + activeDatabase: "other" + ))) + + #expect(issued.contains(.useDatabase(entry))) + #expect(issued.contains(.setDatabaseEnvironment(entry, .production))) + #expect(issued.contains(.removeDatabaseFavorite(entry))) + } + + @Test("The active database omits a redundant switch command") + func activeDatabaseOmitsSwitch() { + let entry = database() + let issued = commands(FavoritesMenuSpec.items(for: FavoritesMenuContext( + clicked: .database(entry), + databaseEntityName: "Database", + activeDatabase: entry.database + ))) + + #expect(!issued.contains(.useDatabase(entry))) + } + @Test("Move to lists every folder except the one the favourite is already in") func moveToSkipsTheCurrentFolder() { let home = SQLFavoriteFolder(name: "Home") diff --git a/TableProTests/Views/Sidebar/FavoritesOutlineSelectionTests.swift b/TableProTests/Views/Sidebar/FavoritesOutlineSelectionTests.swift index 4082d5f03..11ef0adf5 100644 --- a/TableProTests/Views/Sidebar/FavoritesOutlineSelectionTests.swift +++ b/TableProTests/Views/Sidebar/FavoritesOutlineSelectionTests.swift @@ -20,9 +20,18 @@ struct FavoritesOutlineSelectionTests { SQLFavorite(name: name, query: "SELECT 1") } + private func database(_ name: String) -> FavoriteDatabaseEntry { + FavoriteDatabaseEntry(connectionId: UUID(), database: name, environment: .development) + } + @Test("Section titles refuse selection, objects accept it") func headersRefuseSelection() { #expect(FavoritesOutlineSelection.isSelectable(.header("Tables")) == false) + #expect(FavoritesOutlineSelection.isSelectable(.database(database("app")))) + #expect(FavoritesOutlineSelection.isSelectable(.databaseEnvironment(FavoriteDatabaseGroup( + environment: .development, + entries: [database("app")] + )))) #expect(FavoritesOutlineSelection.isSelectable(.table(table("users")))) #expect(FavoritesOutlineSelection.isSelectable(.query(.favorite(favorite("daily"))))) #expect(FavoritesOutlineSelection.isSelectable(.teamQuery(id: "t1", name: "Shared", publishedBy: nil))) @@ -40,6 +49,37 @@ struct FavoritesOutlineSelectionTests { #expect(FavoritesOutlineSelection.selection(for: .query(node), database: nil) == .node(id: node.id)) } + @Test("A database row maps to its stable node id") + func databaseMapsToNodeId() { + let entry = database("analytics") + let selection = FavoritesOutlineSelection.selection(for: .database(entry), database: nil) + + #expect(selection == .node(id: FavoritesOutlineNode.databaseId(entry))) + } + + @Test("Database environment expansion is independent per connection") + @MainActor + func databaseEnvironmentExpansionIsConnectionScoped() { + let firstConnection = UUID() + let secondConnection = UUID() + defer { + FavoritesExpansionState.shared.removeConnection(firstConnection) + FavoritesExpansionState.shared.removeConnection(secondConnection) + } + + #expect(FavoritesExpansion.isDatabaseEnvironmentExpanded(.testing, connectionId: firstConnection)) + #expect(FavoritesExpansion.isDatabaseEnvironmentExpanded(.testing, connectionId: secondConnection)) + + FavoritesExpansion.setDatabaseEnvironmentExpanded( + .testing, + expanded: false, + connectionId: firstConnection + ) + + #expect(!FavoritesExpansion.isDatabaseEnvironmentExpanded(.testing, connectionId: firstConnection)) + #expect(FavoritesExpansion.isDatabaseEnvironmentExpanded(.testing, connectionId: secondConnection)) + } + /// Team Library rows carried no tag at all before, so the keyboard could never reach them. @Test("A Team Library row maps to a selection of its own") func teamQueryMapsToSelection() { @@ -66,6 +106,7 @@ struct FavoritesOutlineSelectionTests { @Test("Type-select uses the name a user would type, never a section title") func typeSelectSkipsHeaders() { #expect(FavoritesOutlineSelection.matchString(for: .header("Tables")) == nil) + #expect(FavoritesOutlineSelection.matchString(for: .database(database("analytics"))) == "analytics") #expect(FavoritesOutlineSelection.matchString(for: .table(table("orders"))) == "orders") #expect(FavoritesOutlineSelection.matchString(for: .query(.favorite(favorite("daily")))) == "daily") #expect( @@ -83,8 +124,16 @@ struct FavoritesOutlineSelectionTests { kind: .query(.folder(SQLFavoriteFolder(name: "Reports"), children: [])) ) let header = FavoritesOutlineNode(id: "c", kind: .header("Queries")) + let databaseGroup = FavoritesOutlineNode( + id: "d", + kind: .databaseEnvironment(FavoriteDatabaseGroup( + environment: .development, + entries: [database("app")] + )) + ) #expect(leaf.isExpandable == false) #expect(branch.isExpandable) #expect(header.isExpandable == false) + #expect(databaseGroup.isExpandable) } } diff --git a/TableProTests/Views/Sidebar/SidebarOutlineScaffoldTests.swift b/TableProTests/Views/Sidebar/SidebarOutlineScaffoldTests.swift index 20dae2597..69c504ab2 100644 --- a/TableProTests/Views/Sidebar/SidebarOutlineScaffoldTests.swift +++ b/TableProTests/Views/Sidebar/SidebarOutlineScaffoldTests.swift @@ -664,6 +664,87 @@ struct DatabaseTreeFavoriteRefreshTests { #expect(zip(favoritePixels, removedPixels).allSatisfy(!=)) } + @Test("Adding and removing a database favorite repaints its star in place") + func databaseFavoriteMutationRepaintsVisibleRow() throws { + let tableSuite = "DatabaseTreeFavoriteRefreshTests.tables.\(UUID().uuidString)" + let databaseSuite = "DatabaseTreeFavoriteRefreshTests.databases.\(UUID().uuidString)" + let syncSuite = "DatabaseTreeFavoriteRefreshTests.database-sync.\(UUID().uuidString)" + let tableDefaults = try #require(UserDefaults(suiteName: tableSuite)) + let databaseDefaults = try #require(UserDefaults(suiteName: databaseSuite)) + let syncDefaults = try #require(UserDefaults(suiteName: syncSuite)) + defer { + tableDefaults.removePersistentDomain(forName: tableSuite) + databaseDefaults.removePersistentDomain(forName: databaseSuite) + syncDefaults.removePersistentDomain(forName: syncSuite) + } + + let metadata = SyncMetadataStorage(userDefaults: syncDefaults) + let tracker = SyncChangeTracker(metadataStorage: metadata) + let tableStorage = FavoriteTablesStorage(userDefaults: tableDefaults, syncTracker: tracker) + let databaseStorage = FavoriteDatabasesStorage(defaults: databaseDefaults) + let coordinator = DatabaseTreeOutlineCoordinator( + favoriteTablesStorage: tableStorage, + favoriteDatabasesStorage: databaseStorage + ) + let connectionId = UUID() + let database = DatabaseTreeNode( + id: "database-shop", + kind: .database(.minimal(name: "shop")) + ) + let outlineView = NSOutlineView() + let scrollView = SidebarOutlineScaffold.makeScrollView( + outlineView: outlineView, + configuration: SidebarOutlineScaffold.Configuration( + columnIdentifier: "DatabaseFavoriteRefreshColumn", + allowsMultipleSelection: true, + rowSizePreference: .medium + ) + ) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: Self.width, height: Self.height), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.appearance = NSAppearance(named: .aqua) + window.contentView = scrollView + + coordinator.connectionId = connectionId + coordinator.databaseType = .postgresql + coordinator.childrenCache[""] = [database] + outlineView.dataSource = coordinator + outlineView.delegate = coordinator + coordinator.attach(outlineView: outlineView) + outlineView.reloadData() + settle(window) + + let cell = try #require( + outlineView.view(atColumn: 0, row: 0, makeIfNecessary: true) as? DatabaseTreeCellView + ) + settle(window) + let host = cell.hostedView + let unfavoritePixels = try trailingPixels(of: cell) + + databaseStorage.setFavorite( + database: "shop", + environment: .production, + connectionId: connectionId + ) + settle(window) + + #expect(coordinator.favoriteDatabaseEnvironment(for: "shop") == .production) + #expect(cell.hostedView === host) + let favoritePixels = try trailingPixels(of: cell) + #expect(unfavoritePixels != favoritePixels) + + databaseStorage.removeFavorite(database: "shop", connectionId: connectionId) + settle(window) + + #expect(coordinator.favoriteDatabaseEnvironment(for: "shop") == nil) + #expect(cell.hostedView === host) + #expect(try trailingPixels(of: cell) != favoritePixels) + } + private func settle(_ window: NSWindow) { window.layoutIfNeeded() RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) diff --git a/docs/features/favorites.mdx b/docs/features/favorites.mdx index 06cea838f..5470c3b2c 100644 --- a/docs/features/favorites.mdx +++ b/docs/features/favorites.mdx @@ -1,9 +1,17 @@ --- title: Favorites -description: Mark tables as favorites and save frequently used queries with optional keyword shortcuts +description: Save databases and tables, and keep frequently used queries with optional keyword shortcuts --- -The Favorites tab in the sidebar has two sections: **Tables** for pinned tables and **Queries** for saved SQL. The sidebar can also show a **Recent** section with the last tables you opened (off by default). +The Favorites tab keeps pinned databases and tables beside saved SQL. The sidebar can also show a **Recent** section with the last tables you opened (off by default). + +## Database Favorites + +In Tree layout, right-click a database and choose **Add to Favorites**, then assign it to Development, Testing, Production, or Unassigned. A filled star marks the database in the tree. + +Favorite databases appear first in the Favorites tab, grouped by environment. Use the Environment menu to show one group, and use the sidebar search field to filter by database or environment name. Double-click a database, or select it and press Return, to make it active. Right-click it to change its environment or remove it. + +Database favorites are stored locally for each connection. Database names that no longer exist stay in Favorites so you can remove them; trying to activate one shows the database driver's normal switch error. ## Table Favorites From 0fdb62914dcfc2dc21ebfdf507b557a1cd3cbfa0 Mon Sep 17 00:00:00 2001 From: Nguyen Van Thang <61111580+sophiathedev@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:02:08 +0700 Subject: [PATCH 2/5] docs(favorites): add feature preview --- docs/images/favorite-databases.png | Bin 0 -> 195255 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/images/favorite-databases.png diff --git a/docs/images/favorite-databases.png b/docs/images/favorite-databases.png new file mode 100644 index 0000000000000000000000000000000000000000..f2b9737c13cb09857d6d764d77a29e52b2d5d308 GIT binary patch literal 195255 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

X7{uSZsa6i9Z&Y1uF{2XR+`4THR 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ⅅqIeS_ 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 P;NQMoN;~7W>z@99=>?uF literal 0 HcmV?d00001 From e51e82a170b5cba9cc8f672e7d594899d9ee77aa Mon Sep 17 00:00:00 2001 From: sophiathedev Date: Tue, 18 Aug 2026 16:04:08 +0700 Subject: [PATCH 3/5] docs(favorites): show database groups --- docs/features/favorites.mdx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/features/favorites.mdx b/docs/features/favorites.mdx index 5470c3b2c..1afb16dbd 100644 --- a/docs/features/favorites.mdx +++ b/docs/features/favorites.mdx @@ -11,6 +11,10 @@ In Tree layout, right-click a database and choose **Add to Favorites**, then ass Favorite databases appear first in the Favorites tab, grouped by environment. Use the Environment menu to show one group, and use the sidebar search field to filter by database or environment name. Double-click a database, or select it and press Return, to make it active. Right-click it to change its environment or remove it. + + Favorite databases grouped into Development, Testing, and Production in the Favorites sidebar + + Database favorites are stored locally for each connection. Database names that no longer exist stay in Favorites so you can remove them; trying to activate one shows the database driver's normal switch error. ## Table Favorites From aee31861729ce0c1d6b5317ce1a31de0cb986412 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 20 Aug 2026 18:09:47 +0700 Subject: [PATCH 4/5] feat(sidebar): make database favorites reachable outside the tree, and sync them --- CHANGELOG.md | 2 + .../SyncRecordType.swift | 2 + .../SyncSchemaFields.swift | 14 + .../SyncSchemaRegistry.swift | 2 + .../SyncSchemaGateTests.swift | 20 +- TablePro/Core/Menu/DatabaseMenuBuilder.swift | 12 + .../Menu/FavoriteDatabaseMenuDelegate.swift | 60 ++++ TablePro/Core/Services/AppServices.swift | 2 + ...itViewController+DatabaseMenuActions.swift | 10 + ...inSplitViewController+MenuValidation.swift | 4 + .../Core/Storage/ConnectionLocalState.swift | 65 ++++ TablePro/Core/Storage/ConnectionStorage.swift | 35 +- .../Storage/FavoriteDatabasesStorage.swift | 169 +++++++-- .../Core/Storage/FavoriteTablesStorage.swift | 18 +- TablePro/Core/Storage/RecentTablesStore.swift | 5 + TablePro/Core/Sync/SyncCoordinator.swift | 56 ++- TablePro/Core/Sync/SyncRecordMapper.swift | 37 ++ TablePro/Core/Sync/SyncScope.swift | 3 +- .../Favorites/FavoriteDatabaseEntry.swift | 2 +- .../FavoriteDatabaseEnvironment.swift | 15 +- .../FavoriteDatabaseEnvironmentFilter.swift | 11 +- .../Favorites/FavoriteDatabaseGrouping.swift | 1 - TablePro/Models/Settings/SyncSettings.swift | 5 + TablePro/Resources/Localizable.xcstrings | 340 ++++++++++++++++++ .../ViewModels/FavoritesExpansionState.swift | 41 ++- .../DatabaseSwitcherPopover.swift | 73 +++- .../Main/MainContentCommandActions.swift | 32 ++ .../Views/Settings/Sections/SyncSection.swift | 1 + TablePro/Views/Shared/FieldDrivenList.swift | 39 +- ...abaseTreeOutlineCoordinator+Commands.swift | 20 +- .../DatabaseTreeOutlineCoordinator+Menu.swift | 8 +- .../DatabaseTreeOutlineCoordinator.swift | 39 +- .../Views/Sidebar/DatabaseTreeRowView.swift | 30 +- .../Views/Sidebar/FavoriteStarButton.swift | 55 +++ .../Views/Sidebar/FavoritesEmptyState.swift | 51 +++ .../Sidebar/FavoritesOutlineCoordinator.swift | 6 +- .../Views/Sidebar/FavoritesOutlineView.swift | 2 +- TablePro/Views/Sidebar/FavoritesTabView.swift | 55 ++- .../Sidebar/Menu/DatabaseTreeMenuSpec.swift | 52 +-- .../Sidebar/Menu/FavoriteDatabaseMenu.swift | 59 +++ .../Sidebar/Menu/FavoritesMenuSpec.swift | 16 +- .../Sidebar/Menu/SidebarMenuCommand.swift | 4 +- .../Views/Sidebar/SidebarPersistenceKey.swift | 23 ++ TablePro/Views/Sidebar/TableRowView.swift | 38 +- .../Core/Menu/MainMenuBuilderTests.swift | 11 + .../FavoriteDatabasesStorageTests.swift | 233 +++++++++--- .../Core/Sync/FavoriteDatabaseSyncTests.swift | 128 +++++++ .../FavoriteDatabaseGroupingTests.swift | 31 +- .../Models/SharedSidebarStateTests.swift | 2 +- .../FavoritesExpansionStateTests.swift | 93 +++++ .../QuickSwitcherViewModelTests.swift | 1 + .../ViewModels/WelcomeViewModelTests.swift | 1 + .../Sidebar/DatabaseTreeMenuSpecTests.swift | 65 +++- .../Sidebar/FavoriteDatabaseMenuTests.swift | 91 +++++ .../Sidebar/FavoritesEmptyStateTests.swift | 81 +++++ .../Sidebar/SidebarOutlineScaffoldTests.swift | 4 +- docs/features/favorites.mdx | 12 +- docs/features/icloud-sync.mdx | 3 +- 58 files changed, 1983 insertions(+), 307 deletions(-) create mode 100644 TablePro/Core/Menu/FavoriteDatabaseMenuDelegate.swift create mode 100644 TablePro/Core/Storage/ConnectionLocalState.swift create mode 100644 TablePro/Views/Sidebar/FavoriteStarButton.swift create mode 100644 TablePro/Views/Sidebar/FavoritesEmptyState.swift create mode 100644 TablePro/Views/Sidebar/Menu/FavoriteDatabaseMenu.swift create mode 100644 TableProTests/Core/Sync/FavoriteDatabaseSyncTests.swift create mode 100644 TableProTests/ViewModels/FavoritesExpansionStateTests.swift create mode 100644 TableProTests/Views/Sidebar/FavoriteDatabaseMenuTests.swift create mode 100644 TableProTests/Views/Sidebar/FavoritesEmptyStateTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e9771c04..b89840bd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Databases can be saved to Favorites and tagged Development, Testing, Production or Unassigned. Add one from the database switcher in the toolbar, from a database row in the Tree sidebar, or from Database > Favorite Database, which works in every sidebar layout. Right-clicking a database inside a multiple selection tags all of them. The Favorites tab lists them first, grouped by environment, with a filter for one environment at a time; double-click one to make it active. Database favorites sync through iCloud and have their own toggle in Settings > Account. (#1553) + - The SQL editor marks each statement in a tab and offers to run it on its own. The statement your cursor is in gets a faint background, so you can see what `Cmd+Enter` will send before you press it, and moving the pointer over the gutter reveals a run button beside every statement. Clicking one runs that statement even if the cursor is somewhere else. The buttons take the same safe mode checks and parameter prompts as any other run, they go dim while a query is running, and VoiceOver reads each one as a button naming the line it starts on. Both the highlight and the buttons can be turned off in Settings > Editor. (#2278) - TablePro notifies you when long-running work finishes while you are in another app, so a slow query no longer means watching a spinner or forgetting it entirely. It covers queries, Execute All, row saves, structure changes, imports, exports, backups, fetch all rows, and queries an AI assistant runs over MCP. It only notifies when the result is not already on screen, and never for work you cancelled. The notification says what happened, how much and how long it took, and clicking it selects the tab that ran the work. A tab that finished while you were elsewhere keeps a dot until you look at it, whether or not you allowed notifications. Turn the whole thing off, pick which kinds you want, or change the 20 second threshold in Settings > Notifications. (#2265) - Help › Acknowledgements lists the 38 open source libraries TablePro ships, each with the exact version that shipped, its license, the copyright lines it asks you to reproduce, and the full license text. Several of these licenses require the text to travel with the app, and it had never been included. Two libraries whose own projects publish no license are listed as unresolved rather than being given a guess. diff --git a/Packages/TableProCore/Sources/TableProSyncTransport/SyncRecordType.swift b/Packages/TableProCore/Sources/TableProSyncTransport/SyncRecordType.swift index 9b536fa76..ec291b62b 100644 --- a/Packages/TableProCore/Sources/TableProSyncTransport/SyncRecordType.swift +++ b/Packages/TableProCore/Sources/TableProSyncTransport/SyncRecordType.swift @@ -8,6 +8,7 @@ public enum SyncRecordType: String, CaseIterable, Sendable { case favorite = "SQLFavorite" case favoriteFolder = "SQLFavoriteFolder" case tableFavorite = "FavoriteTable" + case favoriteDatabase = "FavoriteDatabase" case sshProfile = "SSHProfile" public var recordNamePrefix: String { @@ -19,6 +20,7 @@ public enum SyncRecordType: String, CaseIterable, Sendable { case .favorite: return "Favorite_" case .favoriteFolder: return "FavoriteFolder_" case .tableFavorite: return "FavoriteTable_" + case .favoriteDatabase: return "FavoriteDatabase_" case .sshProfile: return "SSHProfile_" } } diff --git a/Packages/TableProCore/Sources/TableProSyncTransport/SyncSchemaFields.swift b/Packages/TableProCore/Sources/TableProSyncTransport/SyncSchemaFields.swift index ef5bfbba5..8c3eca1b8 100644 --- a/Packages/TableProCore/Sources/TableProSyncTransport/SyncSchemaFields.swift +++ b/Packages/TableProCore/Sources/TableProSyncTransport/SyncSchemaFields.swift @@ -51,6 +51,20 @@ public enum FavoriteTableSyncField: String, SyncSchemaField { ] } +/// Empty on purpose. `FavoriteDatabase` is not in the Production schema yet, so every field is +/// unverified and the gated subscript drops every write, which keeps the type inert rather than +/// letting CloudKit reject the record. Flip this set and `SyncRecordType.verifiedInProduction` +/// together in the commit that carries the refreshed `production-schema.ckdb`. +public enum FavoriteDatabaseSyncField: String, SyncSchemaField { + case connectionId + case database + case environment + case modifiedAtLocal + case schemaVersion + + public static let verifiedInProduction: Set = [] +} + public enum SQLFavoriteSyncField: String, SyncSchemaField { case favoriteId case name diff --git a/Packages/TableProCore/Sources/TableProSyncTransport/SyncSchemaRegistry.swift b/Packages/TableProCore/Sources/TableProSyncTransport/SyncSchemaRegistry.swift index 2ecfb3e6b..f07e9cf06 100644 --- a/Packages/TableProCore/Sources/TableProSyncTransport/SyncSchemaRegistry.swift +++ b/Packages/TableProCore/Sources/TableProSyncTransport/SyncSchemaRegistry.swift @@ -22,6 +22,7 @@ public extension SyncRecordType { case .favorite: return SQLFavoriteSyncField.declaredKeys case .favoriteFolder: return SQLFavoriteFolderSyncField.declaredKeys case .tableFavorite: return FavoriteTableSyncField.declaredKeys + case .favoriteDatabase: return FavoriteDatabaseSyncField.declaredKeys case .sshProfile: return SSHProfileSyncField.declaredKeys } } @@ -35,6 +36,7 @@ public extension SyncRecordType { case .favorite: return SQLFavoriteSyncField.writableKeys case .favoriteFolder: return SQLFavoriteFolderSyncField.writableKeys case .tableFavorite: return FavoriteTableSyncField.writableKeys + case .favoriteDatabase: return FavoriteDatabaseSyncField.writableKeys case .sshProfile: return SSHProfileSyncField.writableKeys } } diff --git a/Packages/TableProCore/Tests/TableProSyncTests/SyncSchemaGateTests.swift b/Packages/TableProCore/Tests/TableProSyncTests/SyncSchemaGateTests.swift index 6f46e5d5b..295cd558c 100644 --- a/Packages/TableProCore/Tests/TableProSyncTests/SyncSchemaGateTests.swift +++ b/Packages/TableProCore/Tests/TableProSyncTests/SyncSchemaGateTests.swift @@ -56,11 +56,23 @@ struct SyncSchemaGateTests { #expect(SampleField.deployed.productionSchemaState == .verified) } - @Test("Every record type the app declares is currently deployed") - func allRecordTypesAreWritable() { - let gated = SyncRecordType.allCases.filter { !$0.isWritable } + /// A type lands here only between the commit that declares it and the commit that carries the + /// refreshed `production-schema.ckdb`. Anything gated and unlisted is a type that will never + /// sync, which is what this test exists to catch; anything listed and no longer gated means the + /// deploy landed and the entry is stale. Comparing sets catches both. + private static let pendingProductionDeploy: Set = [.favoriteDatabase] - #expect(gated.isEmpty, "Gated record types: \(gated.map(\.rawValue).sorted())") + @Test("Every record type the app declares is deployed, or is explicitly awaiting deployment") + func allRecordTypesAreWritable() { + let gated = Set(SyncRecordType.allCases.filter { !$0.isWritable }) + + #expect( + gated == Self.pendingProductionDeploy, + """ + Gated record types: \(gated.map(\.rawValue).sorted()). \ + Awaiting deployment: \(Self.pendingProductionDeploy.map(\.rawValue).sorted()) + """ + ) } @Test("Records of a deployed type are published") diff --git a/TablePro/Core/Menu/DatabaseMenuBuilder.swift b/TablePro/Core/Menu/DatabaseMenuBuilder.swift index 1bdef92b4..a28291dc5 100644 --- a/TablePro/Core/Menu/DatabaseMenuBuilder.swift +++ b/TablePro/Core/Menu/DatabaseMenuBuilder.swift @@ -55,6 +55,7 @@ enum DatabaseMenuBuilder { action: #selector(MainSplitViewController.editViewDefinition(_:)) ), schemaSubmenu(), + favoriteDatabaseSubmenu(), maintenanceSubmenu(), MenuItemFactory.item( String(localized: "Truncate Table"), @@ -91,6 +92,17 @@ enum DatabaseMenuBuilder { ]) } + private static let favoriteDatabaseDelegate = FavoriteDatabaseMenuDelegate() + + /// A literal title, like every other item here: System Settings binds an App Shortcut to a menu + /// item's exact title, so a title that flipped between "Add" and "Remove" would break the bind. + /// The submenu reports the current state with a checkmark instead. + private static func favoriteDatabaseSubmenu() -> NSMenuItem { + let container = MenuItemFactory.submenu(String(localized: "Favorite Database"), items: []) + container.submenu?.delegate = favoriteDatabaseDelegate + return container + } + private static let maintenanceDelegate = MaintenanceMenuDelegate() private static func maintenanceSubmenu() -> NSMenuItem { diff --git a/TablePro/Core/Menu/FavoriteDatabaseMenuDelegate.swift b/TablePro/Core/Menu/FavoriteDatabaseMenuDelegate.swift new file mode 100644 index 000000000..d03a480d1 --- /dev/null +++ b/TablePro/Core/Menu/FavoriteDatabaseMenuDelegate.swift @@ -0,0 +1,60 @@ +// +// FavoriteDatabaseMenuDelegate.swift +// TablePro +// + +import AppKit + +/// Whether the active database is a favorite, and which environment it carries, depends on the live +/// connection, so the submenu is filled when it opens. Built on the same shape as +/// `SchemaMenuDelegate`, including the responder-chain lookup that resolves the window the chosen +/// item will act on. +/// +/// The menu bar is the keyboard path to this feature. The row star is hover-revealed and the +/// context menus need a right-click, so without this the whole feature is pointer-only. +@MainActor +final class FavoriteDatabaseMenuDelegate: NSObject, NSMenuDelegate { + private static let setAction = #selector(MainSplitViewController.setFavoriteDatabaseEnvironment(_:)) + private static let removeAction = #selector(MainSplitViewController.removeFavoriteDatabase(_:)) + + func menuNeedsUpdate(_ menu: NSMenu) { + menu.removeAllItems() + let controller = NSApp.target(forAction: Self.setAction, to: nil, from: nil) as? MainSplitViewController + guard let actions = controller?.commandActions, actions.canFavoriteActiveDatabase else { + addPlaceholder(to: menu) + return + } + let state = FavoriteDatabaseSelectionState(environments: [actions.activeDatabaseFavoriteEnvironment]) + for item in FavoriteDatabaseMenu.environmentItems(for: state) { + menu.addItem(environmentItem(item)) + } + guard state.hasFavorite else { return } + menu.addItem(.separator()) + menu.addItem(NSMenuItem(title: FavoriteDatabaseMenu.removeTitle, action: Self.removeAction, keyEquivalent: "")) + } + + private func environmentItem(_ entry: FavoriteDatabaseMenu.EnvironmentItem) -> NSMenuItem { + let item = NSMenuItem(title: entry.title, action: Self.setAction, keyEquivalent: "") + item.target = nil + item.representedObject = entry.environment.rawValue + item.state = entry.isOn ? .on : .off + return item + } + + private func addPlaceholder(to menu: NSMenu) { + let empty = NSMenuItem(title: String(localized: "No Database Selected"), action: nil, keyEquivalent: "") + empty.isEnabled = false + menu.addItem(empty) + } + + /// Keeps AppKit's key-equivalent search from rebuilding the menu on every modified keystroke, + /// which would query the favorites store for items that carry no key equivalent. + func menuHasKeyEquivalent( + _ menu: NSMenu, + for event: NSEvent, + target: AutoreleasingUnsafeMutablePointer, + action: UnsafeMutablePointer + ) -> Bool { + false + } +} diff --git a/TablePro/Core/Services/AppServices.swift b/TablePro/Core/Services/AppServices.swift index 09b5d0cab..681e79bdc 100644 --- a/TablePro/Core/Services/AppServices.swift +++ b/TablePro/Core/Services/AppServices.swift @@ -19,6 +19,7 @@ struct AppServices { let schemaProviderRegistry: SchemaProviderRegistry let sqlFavoriteManager: SQLFavoriteManager let favoriteTablesStorage: FavoriteTablesStorage + let favoriteDatabasesStorage: FavoriteDatabasesStorage let aiChatStorage: AIChatStorage let aiKeyStorage: AIKeyStorage let groupStorage: GroupStorage @@ -48,6 +49,7 @@ struct AppServices { schemaProviderRegistry: .shared, sqlFavoriteManager: .shared, favoriteTablesStorage: .shared, + favoriteDatabasesStorage: .shared, aiChatStorage: .shared, aiKeyStorage: .shared, groupStorage: .shared, diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+DatabaseMenuActions.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+DatabaseMenuActions.swift index 9b29bd9af..1d8a313c5 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+DatabaseMenuActions.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+DatabaseMenuActions.swift @@ -53,6 +53,16 @@ extension MainSplitViewController { Task { await coordinator.switchSchema(to: schema) } } + @objc func setFavoriteDatabaseEnvironment(_ sender: Any?) { + guard let raw = (sender as? NSMenuItem)?.representedObject as? String, + let environment = FavoriteDatabaseEnvironment(rawValue: raw) else { return } + commandActions?.setActiveDatabaseFavorite(environment: environment) + } + + @objc func removeFavoriteDatabase(_ sender: Any?) { + commandActions?.removeActiveDatabaseFavorite() + } + @objc func truncateTable(_ sender: Any?) { commandActions?.truncateTables() } diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift index 33e5081e0..06634e040 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift @@ -60,6 +60,7 @@ struct MenuValidationContext: Equatable { var supportsUserManagement = false var supportsSchemaSwitching = false var canFilterDatabases = false + var canFavoriteActiveDatabase = false var hasDatabaseFilter = false } @@ -182,6 +183,8 @@ extension MainSplitViewController: NSMenuItemValidation { return context.isConnected && context.hasMaintenanceOperations case #selector(switchToSchema(_:)): return context.isConnected && context.supportsSchemaSwitching + case #selector(setFavoriteDatabaseEnvironment(_:)), #selector(removeFavoriteDatabase(_:)): + return context.isConnected && context.canFavoriteActiveDatabase case #selector(filterDatabases(_:)): return context.isConnected && context.canFilterDatabases case #selector(showAllDatabases(_:)): @@ -255,6 +258,7 @@ extension MainSplitViewController: NSMenuItemValidation { supportsUserManagement: actions.supportsUserManagement, supportsSchemaSwitching: actions.supportsSchemaSwitching, canFilterDatabases: actions.canFilterDatabases, + canFavoriteActiveDatabase: actions.canFavoriteActiveDatabase, hasDatabaseFilter: actions.hasDatabaseFilter ) } diff --git a/TablePro/Core/Storage/ConnectionLocalState.swift b/TablePro/Core/Storage/ConnectionLocalState.swift new file mode 100644 index 000000000..3c3d17d2c --- /dev/null +++ b/TablePro/Core/Storage/ConnectionLocalState.swift @@ -0,0 +1,65 @@ +// +// ConnectionLocalState.swift +// TablePro +// + +import Foundation + +/// Everything a deleted connection leaves behind on this device, cleaned up in one place. +/// +/// The list used to be written out at each of the three delete sites, which is how they drifted: +/// the remote-deletion path cleared two stores where the local one cleared nine, and no site had +/// ever removed a single `SidebarPersistenceKey`. A store added here is cleaned up everywhere. +@MainActor +internal enum ConnectionLocalState { + /// Who deleted the connection. A local delete leaves tombstones so the other devices follow; + /// a remote delete must not, or it pushes back a deletion the sender already made. + internal enum Origin { + case local + case remote + } + + internal static func purge( + connectionIds: Set, + origin: Origin, + appSettings: AppSettingsStorage = .shared + ) { + guard !connectionIds.isEmpty else { return } + + for connectionId in connectionIds { + purgeLiveState(connectionId) + appSettings.saveLastDatabase(nil, for: connectionId) + appSettings.saveLastSchema(nil, for: connectionId) + purgeFavorites(connectionId, origin: origin) + SidebarPersistenceKey.removeAll(connectionId: connectionId) + RecentTablesStore.shared.removeEntries(for: connectionId) + HistoryPanelPreferencesStorage.remove(for: connectionId) + QueryInsightsPreferencesStorage.remove(for: connectionId) + } + + FilterSettingsStorage.shared.removeFilters(for: connectionIds) + DatabaseTreeFilterStorage.shared.removeFilters(for: connectionIds) + RecentlyClosedTabStore.shared.removeEntries(for: connectionIds) + } + + /// The in-memory registries go first. A live `SharedSidebarState` for this connection rewrites + /// its own defaults keys on the next mutation, so removing the keys under it achieves nothing. + private static func purgeLiveState(_ connectionId: UUID) { + SharedSidebarState.removeConnection(connectionId) + SidebarViewModel.removeConnection(connectionId) + HistoryPanelState.removeConnection(connectionId) + QuickSwitcherCatalogStore.shared.removeConnection(connectionId) + FavoritesExpansionState.shared.removeConnection(connectionId) + } + + private static func purgeFavorites(_ connectionId: UUID, origin: Origin) { + switch origin { + case .local: + FavoriteTablesStorage.shared.removeFavorites(for: connectionId) + FavoriteDatabasesStorage.shared.removeFavorites(for: connectionId) + case .remote: + FavoriteTablesStorage.shared.removeFavoritesWithoutSync(for: connectionId) + FavoriteDatabasesStorage.shared.removeFavoritesWithoutSync(for: connectionId) + } + } +} diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index 2d093d9ce..d670c8259 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -286,18 +286,11 @@ final class ConnectionStorage { let secureFieldIds = Self.secureFieldIds(for: connection.type) deleteAllPluginSecureFields(for: connection.id, fieldIds: secureFieldIds) - let appSettings = appSettingsProvider() - appSettings.saveLastDatabase(nil, for: connection.id) - appSettings.saveLastSchema(nil, for: connection.id) - - FavoriteTablesStorage.shared.removeFavorites(for: connection.id) - FavoriteDatabasesStorage.shared.removeFavorites(for: connection.id) - FavoritesExpansionState.shared.removeConnection(connection.id) - FilterSettingsStorage.shared.removeFilters(for: connection.id) - DatabaseTreeFilterStorage.shared.removeFilter(for: connection.id) - RecentlyClosedTabStore.shared.removeEntries(for: connection.id) - HistoryPanelPreferencesStorage.remove(for: connection.id) - QueryInsightsPreferencesStorage.remove(for: connection.id) + ConnectionLocalState.purge( + connectionIds: [connection.id], + origin: .local, + appSettings: appSettingsProvider() + ) Task { await SQLFavoriteManager.shared.removeFavoritesAndFolders(for: connection.id) await QueryHistoryManager.shared.clear( @@ -330,20 +323,12 @@ final class ConnectionStorage { deleteSOCKSProxyPassword(for: conn.id) let fields = Self.secureFieldIds(for: conn.type) deleteAllPluginSecureFields(for: conn.id, fieldIds: fields) - let appSettings = appSettingsProvider() - appSettings.saveLastDatabase(nil, for: conn.id) - appSettings.saveLastSchema(nil, for: conn.id) - FavoriteTablesStorage.shared.removeFavorites(for: conn.id) - FavoriteDatabasesStorage.shared.removeFavorites(for: conn.id) - FavoritesExpansionState.shared.removeConnection(conn.id) - } - FilterSettingsStorage.shared.removeFilters(for: idsToDelete) - DatabaseTreeFilterStorage.shared.removeFilters(for: idsToDelete) - RecentlyClosedTabStore.shared.removeEntries(for: idsToDelete) - for id in idsToDelete { - HistoryPanelPreferencesStorage.remove(for: id) - QueryInsightsPreferencesStorage.remove(for: id) } + ConnectionLocalState.purge( + connectionIds: idsToDelete, + origin: .local, + appSettings: appSettingsProvider() + ) Task { for conn in connectionsToDelete { await SQLFavoriteManager.shared.removeFavoritesAndFolders(for: conn.id) diff --git a/TablePro/Core/Storage/FavoriteDatabasesStorage.swift b/TablePro/Core/Storage/FavoriteDatabasesStorage.swift index eafc4e877..0b63f704b 100644 --- a/TablePro/Core/Storage/FavoriteDatabasesStorage.swift +++ b/TablePro/Core/Storage/FavoriteDatabasesStorage.swift @@ -5,34 +5,49 @@ import Foundation import os +import TableProSyncTransport extension Notification.Name { internal static let favoriteDatabasesDidChange = Notification.Name("FavoriteDatabasesDidChange") } +/// Every entry lives under one key rather than one key per connection, so a sync push can resolve a +/// dirty id back to its entry without knowing which connections exist, and so deleting a connection +/// leaves nothing behind to forget. `FavoriteTablesStorage` is the same shape. @MainActor internal final class FavoriteDatabasesStorage { internal static let shared = FavoriteDatabasesStorage() private static let logger = Logger(subsystem: "com.TablePro", category: "FavoriteDatabasesStorage") + private static let storageKey = "com.TablePro.favoriteDatabases" + private let defaults: UserDefaults + private let syncTracker: SyncChangeTracker + private var cache: Set? - internal init(defaults: UserDefaults = AppStorageEnvironment.shared.defaults) { + internal init( + defaults: UserDefaults = AppStorageEnvironment.shared.defaults, + syncTracker: SyncChangeTracker = .shared + ) { self.defaults = defaults + self.syncTracker = syncTracker } - internal func favorites(for connectionId: UUID) -> Set { - guard let data = defaults.data(forKey: key(for: connectionId)), + internal func loadFavorites() -> Set { + if let cache { return cache } + guard let data = defaults.data(forKey: Self.storageKey), let decoded = try? JSONDecoder().decode(Set.self, from: data) - else { return [] } - return decoded.filter { $0.connectionId == connectionId && !$0.database.isEmpty } + else { + cache = [] + return [] + } + let valid = decoded.filter { !$0.database.isEmpty } + cache = valid + return valid } - internal func environment( - for database: String, - connectionId: UUID - ) -> FavoriteDatabaseEnvironment? { - favorites(for: connectionId).first { $0.database == database }?.environment + internal func favorites(for connectionId: UUID) -> Set { + loadFavorites().filter { $0.connectionId == connectionId } } internal func setFavorite( @@ -40,48 +55,132 @@ internal final class FavoriteDatabasesStorage { environment: FavoriteDatabaseEnvironment, connectionId: UUID ) { - guard !database.isEmpty else { return } - var entries = favorites(for: connectionId) - entries = Set(entries.filter { $0.database != database }) - entries.insert(FavoriteDatabaseEntry( + let entry = FavoriteDatabaseEntry( connectionId: connectionId, database: database, environment: environment - )) - persist(entries, connectionId: connectionId) + ) + notify(after: mutate { Self.upsert(entry, into: &$0) }) + } + + internal func setFavoriteWithoutSync(_ entry: FavoriteDatabaseEntry) { + notify(after: mutate { Self.upsert(entry, into: &$0) }, skipSync: true) } internal func removeFavorite(database: String, connectionId: UUID) { - var entries = favorites(for: connectionId) - let originalCount = entries.count - entries = Set(entries.filter { $0.database != database }) - guard entries.count != originalCount else { return } - persist(entries, connectionId: connectionId) + notify(after: mutate { favorites in + guard let existing = favorites.first(where: { + $0.connectionId == connectionId && $0.database == database + }) else { return .noChange } + favorites.remove(existing) + return .removed(existing) + }) + } + + internal func removeFavoriteWithoutSync(id: String) { + notify(after: mutate { favorites in + guard let entry = favorites.first(where: { Self.syncId(for: $0) == id }) else { return .noChange } + favorites.remove(entry) + return .removed(entry) + }, skipSync: true) } internal func removeFavorites(for connectionId: UUID) { - let key = key(for: connectionId) - guard defaults.object(forKey: key) != nil else { return } - defaults.removeObject(forKey: key) + removeFavorites(for: connectionId, skipSync: false) + } + + internal func removeFavoritesWithoutSync(for connectionId: UUID) { + removeFavorites(for: connectionId, skipSync: true) + } + + /// The composite id never includes the environment. A record keyed on a mutable payload is + /// orphaned the moment that payload changes, so re-tagging a database would leave the old + /// record behind and push a second one beside it. + internal nonisolated static func syncId(for entry: FavoriteDatabaseEntry) -> String { + (entry.connectionId.uuidString + "|" + entry.database).sha256 + } + + private func removeFavorites(for connectionId: UUID, skipSync: Bool) { + var favorites = loadFavorites() + let removed = favorites.filter { $0.connectionId == connectionId } + guard !removed.isEmpty else { return } + favorites.subtract(removed) + persist(favorites) + + guard !skipSync else { + postChangeNotification() + return + } + for entry in removed { + syncTracker.markDeleted(.favoriteDatabase, id: Self.syncId(for: entry)) + } + postChangeNotification() + } + + private enum TrackedAction { + case noChange + case changed(FavoriteDatabaseEntry) + case removed(FavoriteDatabaseEntry) + } + + /// Re-picking the environment a database already has is not a change. Persisting it anyway + /// posts a notification that rebuilds every visible tree row in every window for nothing. + private static func upsert( + _ entry: FavoriteDatabaseEntry, + into favorites: inout Set + ) -> TrackedAction { + guard !entry.database.isEmpty else { return .noChange } + if let existing = favorites.first(where: { $0.id == entry.id }) { + guard existing.environment != entry.environment else { return .noChange } + favorites.remove(existing) + } + favorites.insert(entry) + return .changed(entry) + } + + private func mutate(_ block: (inout Set) -> TrackedAction) -> TrackedAction { + var favorites = loadFavorites() + let action = block(&favorites) + guard case .noChange = action else { + persist(favorites) + return action + } + return action + } + + /// Persist first, then notify: `markDeleted` posts a change that can start a sync, and a sync + /// that reads a file still holding the deleted entry re-uploads it. + private func notify(after action: TrackedAction, skipSync: Bool = false) { + switch action { + case .noChange: + return + case .changed(let entry): + if !skipSync { + syncTracker.markDirty(.favoriteDatabase, id: Self.syncId(for: entry)) + } + postChangeNotification() + case .removed(let entry): + if !skipSync { + syncTracker.markDeleted(.favoriteDatabase, id: Self.syncId(for: entry)) + } + postChangeNotification() + } + } + + private func postChangeNotification() { NotificationCenter.default.post(name: .favoriteDatabasesDidChange, object: self) } - private func persist(_ entries: Set, connectionId: UUID) { - let key = key(for: connectionId) - guard !entries.isEmpty else { - defaults.removeObject(forKey: key) - NotificationCenter.default.post(name: .favoriteDatabasesDidChange, object: self) + private func persist(_ favorites: Set) { + cache = favorites + guard !favorites.isEmpty else { + defaults.removeObject(forKey: Self.storageKey) return } do { - defaults.set(try JSONEncoder().encode(entries), forKey: key) - NotificationCenter.default.post(name: .favoriteDatabasesDidChange, object: self) + defaults.set(try JSONEncoder().encode(favorites), forKey: Self.storageKey) } catch { Self.logger.error("Failed to encode favorite databases: \(error.localizedDescription, privacy: .public)") } } - - private func key(for connectionId: UUID) -> String { - "com.TablePro.favoriteDatabases.\(connectionId.uuidString)" - } } diff --git a/TablePro/Core/Storage/FavoriteTablesStorage.swift b/TablePro/Core/Storage/FavoriteTablesStorage.swift index e55cd0845..4fa8567d7 100644 --- a/TablePro/Core/Storage/FavoriteTablesStorage.swift +++ b/TablePro/Core/Storage/FavoriteTablesStorage.swift @@ -107,6 +107,18 @@ final class FavoriteTablesStorage { @discardableResult func removeFavorites(for connectionId: UUID) -> [FavoriteEntry] { + removeFavorites(for: connectionId, skipSync: false) + } + + /// Used when another device deleted the connection. Marking tombstones here would push its own + /// deletion straight back at it. + @discardableResult + func removeFavoritesWithoutSync(for connectionId: UUID) -> [FavoriteEntry] { + removeFavorites(for: connectionId, skipSync: true) + } + + @discardableResult + private func removeFavorites(for connectionId: UUID, skipSync: Bool) -> [FavoriteEntry] { var removed: [FavoriteEntry] = [] lock.lock() var favorites = _loadFavorites() @@ -119,8 +131,10 @@ final class FavoriteTablesStorage { lock.unlock() guard !removed.isEmpty else { return [] } - for entry in removed { - syncTracker.markDeleted(.tableFavorite, id: Self.syncId(for: entry)) + if !skipSync { + for entry in removed { + syncTracker.markDeleted(.tableFavorite, id: Self.syncId(for: entry)) + } } NotificationCenter.default.post(name: .favoriteTablesDidChange, object: nil) return removed diff --git a/TablePro/Core/Storage/RecentTablesStore.swift b/TablePro/Core/Storage/RecentTablesStore.swift index e8a828ea3..2f41352fb 100644 --- a/TablePro/Core/Storage/RecentTablesStore.swift +++ b/TablePro/Core/Storage/RecentTablesStore.swift @@ -85,6 +85,11 @@ final class RecentTablesStore { return updated } + func removeEntries(for connectionId: UUID) { + defaults.removeObject(forKey: PreferenceKeys.recentTables(connectionId: connectionId).name) + defaults.removeObject(forKey: legacyKeyPrefix + connectionId.uuidString) + } + static func merged(_ entry: RecentTableEntry, into existing: [RecentTableEntry]) -> [RecentTableEntry] { var result = existing.filter { $0.id != entry.id } result.insert(entry, at: 0) diff --git a/TablePro/Core/Sync/SyncCoordinator.swift b/TablePro/Core/Sync/SyncCoordinator.swift index cf29cfa10..a1b47b936 100644 --- a/TablePro/Core/Sync/SyncCoordinator.swift +++ b/TablePro/Core/Sync/SyncCoordinator.swift @@ -199,6 +199,11 @@ final class SyncCoordinator { changeTracker.markDirty(.tableFavorite, id: FavoriteTablesStorage.syncId(for: entry)) } + let favoriteDatabases = services.favoriteDatabasesStorage.loadFavorites() + for entry in favoriteDatabases { + changeTracker.markDirty(.favoriteDatabase, id: FavoriteDatabasesStorage.syncId(for: entry)) + } + for category in AppSettingsCategory.synced + [CustomSlashCommandStorage.syncCategory] { changeTracker.markDirty(.settings, id: category) } @@ -336,6 +341,10 @@ final class SyncCoordinator { collectDirtyTableFavorites(into: &recordsToSave, deletions: &recordIDsToDelete, zoneID: zoneID) } + if settings.syncDatabaseFavorites { + collectDirtyDatabaseFavorites(into: &recordsToSave, deletions: &recordIDsToDelete, zoneID: zoneID) + } + if settings.syncSQLFavorites { await collectDirtySQLFavorites(into: &recordsToSave, deletions: &recordIDsToDelete, zoneID: zoneID) } @@ -435,6 +444,7 @@ final class SyncCoordinator { let tagTombstoneIds = Set(metadataStorage.tombstones(for: .tag).map(\.id)) let sshTombstoneIds = Set(metadataStorage.tombstones(for: .sshProfile).map(\.id)) let tableFavoriteTombstoneIds = Set(metadataStorage.tombstones(for: .tableFavorite).map(\.id)) + let databaseFavoriteTombstoneIds = Set(metadataStorage.tombstones(for: .favoriteDatabase).map(\.id)) let sqlFavoriteTombstoneIds = Set(metadataStorage.tombstones(for: .favorite).map(\.id)) let sqlFolderTombstoneIds = Set(metadataStorage.tombstones(for: .favoriteFolder).map(\.id)) var remoteFavorites: [SQLFavorite] = [] @@ -460,6 +470,8 @@ final class SyncCoordinator { applyRemoteSettings(record) case SyncRecordType.tableFavorite.rawValue where settings.syncTableFavorites: applyRemoteTableFavorite(record, tombstoneIds: tableFavoriteTombstoneIds) + case SyncRecordType.favoriteDatabase.rawValue where settings.syncDatabaseFavorites: + applyRemoteDatabaseFavorite(record, tombstoneIds: databaseFavoriteTombstoneIds) case SyncRecordType.favorite.rawValue where settings.syncSQLFavorites: if let favorite = try? SyncRecordMapper.sqlFavorite(from: record), !sqlFavoriteTombstoneIds.contains(favorite.id.uuidString) { @@ -517,11 +529,7 @@ final class SyncCoordinator { if !services.connectionStorage.saveConnections(connections) { Self.logger.error("Failed to apply remote connection deletions: persistence error") } else { - FilterSettingsStorage.shared.removeFilters(for: connectionIdsToDelete) - for id in connectionIdsToDelete { - FavoriteDatabasesStorage.shared.removeFavorites(for: id) - FavoritesExpansionState.shared.removeConnection(id) - } + ConnectionLocalState.purge(connectionIds: connectionIdsToDelete, origin: .remote) let favoriteManager = services.sqlFavoriteManager Task { for id in connectionIdsToDelete { @@ -720,6 +728,24 @@ final class SyncCoordinator { return services.favoriteTablesStorage.addFavoriteWithoutSync(entry) } + /// Upserts rather than inserts. A database favorite carries a mutable payload, the environment + /// tag, so an insert-if-absent apply would keep the local tag and silently drop the remote one. + private func applyRemoteDatabaseFavorite(_ record: CKRecord, tombstoneIds: Set) { + let entry: FavoriteDatabaseEntry + do { + entry = try SyncRecordMapper.favoriteDatabase(from: record) + } catch { + let recordName = record.recordID.recordName + let message = error.localizedDescription + Self.logger.error( + "Skipping remote favorite database \(recordName, privacy: .public): \(message, privacy: .public)" + ) + return + } + guard !tombstoneIds.contains(FavoriteDatabasesStorage.syncId(for: entry)) else { return } + services.favoriteDatabasesStorage.setFavoriteWithoutSync(entry) + } + // MARK: - Observers private func observeAccountChanges() { @@ -986,4 +1012,24 @@ final class SyncCoordinator { ) } } + + private func collectDirtyDatabaseFavorites( + into records: inout [CKRecord], + deletions: inout [CKRecord.ID], + zoneID: CKRecordZone.ID + ) { + let dirtyIds = changeTracker.dirtyRecords(for: .favoriteDatabase) + if !dirtyIds.isEmpty { + let favorites = services.favoriteDatabasesStorage.loadFavorites() + for entry in favorites where dirtyIds.contains(FavoriteDatabasesStorage.syncId(for: entry)) { + records.append(SyncRecordMapper.toCKRecord(favoriteDatabase: entry, in: zoneID)) + } + } + + for tombstone in metadataStorage.tombstones(for: .favoriteDatabase) { + deletions.append( + SyncRecordMapper.recordID(type: .favoriteDatabase, id: tombstone.id, in: zoneID) + ) + } + } } diff --git a/TablePro/Core/Sync/SyncRecordMapper.swift b/TablePro/Core/Sync/SyncRecordMapper.swift index a6a363845..86b7b0767 100644 --- a/TablePro/Core/Sync/SyncRecordMapper.swift +++ b/TablePro/Core/Sync/SyncRecordMapper.swift @@ -390,6 +390,43 @@ struct SyncRecordMapper { ) } + // MARK: - Favorite Database + + static func toCKRecord(favoriteDatabase entry: FavoriteDatabaseEntry, in zone: CKRecordZone.ID) -> CKRecord { + let favoriteId = FavoriteDatabasesStorage.syncId(for: entry) + let recordID = recordID(type: .favoriteDatabase, id: favoriteId, in: zone) + let record = CKRecord(recordType: SyncRecordType.favoriteDatabase.rawValue, recordID: recordID) + + let fields = record.fields(FavoriteDatabaseSyncField.self) + fields[.connectionId] = entry.connectionId.uuidString + fields[.database] = entry.database + fields[.environment] = entry.environment.rawValue + fields[.modifiedAtLocal] = Date() + fields[.schemaVersion] = schemaVersion + + return record + } + + /// An environment this build does not know decodes to `.unassigned` rather than throwing, so a + /// device on an older version keeps the favorite instead of dropping the whole record. + static func favoriteDatabase(from record: CKRecord) throws -> FavoriteDatabaseEntry { + let fields = record.fields(FavoriteDatabaseSyncField.self) + guard let database = fields[.database] as? String, !database.isEmpty else { + throw SyncDecodeError.missingRequiredField("database") + } + guard let connectionIdString = fields[.connectionId] as? String, + let connectionId = UUID(uuidString: connectionIdString) else { + throw SyncDecodeError.missingRequiredField("connectionId") + } + let environment = (fields[.environment] as? String) + .flatMap(FavoriteDatabaseEnvironment.init(rawValue:)) ?? .unassigned + return FavoriteDatabaseEntry( + connectionId: connectionId, + database: database, + environment: environment + ) + } + // MARK: - SQL Favorite static func toCKRecord(sqlFavorite favorite: SQLFavorite, in zone: CKRecordZone.ID) -> CKRecord { diff --git a/TablePro/Core/Sync/SyncScope.swift b/TablePro/Core/Sync/SyncScope.swift index f7468883e..f4e4bd95f 100644 --- a/TablePro/Core/Sync/SyncScope.swift +++ b/TablePro/Core/Sync/SyncScope.swift @@ -14,7 +14,8 @@ enum SyncScope: Equatable { extension SyncRecordType { var syncScope: SyncScope { switch self { - case .connection, .group, .tag, .settings, .favorite, .favoriteFolder, .tableFavorite, .sshProfile: + case .connection, .group, .tag, .settings, .favorite, .favoriteFolder, + .tableFavorite, .favoriteDatabase, .sshProfile: return .synced } } diff --git a/TablePro/Models/Favorites/FavoriteDatabaseEntry.swift b/TablePro/Models/Favorites/FavoriteDatabaseEntry.swift index f35de96a9..0fe365c67 100644 --- a/TablePro/Models/Favorites/FavoriteDatabaseEntry.swift +++ b/TablePro/Models/Favorites/FavoriteDatabaseEntry.swift @@ -35,6 +35,6 @@ internal struct FavoriteDatabaseEntry: Codable, Hashable, Identifiable, Sendable connectionId = try container.decode(UUID.self, forKey: .connectionId) database = try container.decode(String.self, forKey: .database) let rawEnvironment = try container.decodeIfPresent(String.self, forKey: .environment) - environment = rawEnvironment.flatMap(FavoriteDatabaseEnvironment.init(rawValue:)) ?? .none + environment = rawEnvironment.flatMap(FavoriteDatabaseEnvironment.init(rawValue:)) ?? .unassigned } } diff --git a/TablePro/Models/Favorites/FavoriteDatabaseEnvironment.swift b/TablePro/Models/Favorites/FavoriteDatabaseEnvironment.swift index b3f76ff3c..454924d15 100644 --- a/TablePro/Models/Favorites/FavoriteDatabaseEnvironment.swift +++ b/TablePro/Models/Favorites/FavoriteDatabaseEnvironment.swift @@ -5,25 +5,20 @@ import Foundation +/// One name per bucket. The group header, the filter and both menus all read `title`, because a +/// second spelling for the same bucket reads as a second bucket and costs a second translation. internal enum FavoriteDatabaseEnvironment: String, CaseIterable, Codable, Sendable { case development case testing case production - case none + case unassigned internal var title: String { switch self { case .development: String(localized: "Development") case .testing: String(localized: "Testing") case .production: String(localized: "Production") - case .none: String(localized: "Unassigned") - } - } - - internal var menuTitle: String { - switch self { - case .none: String(localized: "No Environment") - case .development, .testing, .production: title + case .unassigned: String(localized: "Unassigned") } } @@ -32,7 +27,7 @@ internal enum FavoriteDatabaseEnvironment: String, CaseIterable, Codable, Sendab case .development: "wrench.and.screwdriver" case .testing: "checkmark.circle" case .production: "lock.shield" - case .none: "tray" + case .unassigned: "tray" } } } diff --git a/TablePro/Models/Favorites/FavoriteDatabaseEnvironmentFilter.swift b/TablePro/Models/Favorites/FavoriteDatabaseEnvironmentFilter.swift index 2d98dde05..3fcef4dbf 100644 --- a/TablePro/Models/Favorites/FavoriteDatabaseEnvironmentFilter.swift +++ b/TablePro/Models/Favorites/FavoriteDatabaseEnvironmentFilter.swift @@ -13,13 +13,8 @@ internal enum FavoriteDatabaseEnvironmentFilter: String, CaseIterable, Sendable case unassigned internal var title: String { - switch self { - case .all: String(localized: "All Environments") - case .development: FavoriteDatabaseEnvironment.development.title - case .testing: FavoriteDatabaseEnvironment.testing.title - case .production: FavoriteDatabaseEnvironment.production.title - case .unassigned: FavoriteDatabaseEnvironment.none.title - } + guard let environment else { return String(localized: "All Environments") } + return environment.title } internal var environment: FavoriteDatabaseEnvironment? { @@ -28,7 +23,7 @@ internal enum FavoriteDatabaseEnvironmentFilter: String, CaseIterable, Sendable case .development: .development case .testing: .testing case .production: .production - case .unassigned: FavoriteDatabaseEnvironment.none + case .unassigned: .unassigned } } } diff --git a/TablePro/Models/Favorites/FavoriteDatabaseGrouping.swift b/TablePro/Models/Favorites/FavoriteDatabaseGrouping.swift index 0f6a06124..bc2e030ab 100644 --- a/TablePro/Models/Favorites/FavoriteDatabaseGrouping.swift +++ b/TablePro/Models/Favorites/FavoriteDatabaseGrouping.swift @@ -15,7 +15,6 @@ internal enum FavoriteDatabaseGrouping { guard filter.environment == nil || entry.environment == filter.environment else { return false } guard !searchText.isEmpty else { return true } return entry.database.localizedStandardContains(searchText) - || entry.environment.title.localizedStandardContains(searchText) } return FavoriteDatabaseEnvironment.allCases.compactMap { environment in diff --git a/TablePro/Models/Settings/SyncSettings.swift b/TablePro/Models/Settings/SyncSettings.swift index c2a7f7bbe..de23b576e 100644 --- a/TablePro/Models/Settings/SyncSettings.swift +++ b/TablePro/Models/Settings/SyncSettings.swift @@ -16,6 +16,7 @@ struct SyncSettings: Codable, Equatable { var syncPasswords: Bool var syncSSHProfiles: Bool var syncTableFavorites: Bool + var syncDatabaseFavorites: Bool var syncSQLFavorites: Bool init( @@ -26,6 +27,7 @@ struct SyncSettings: Codable, Equatable { syncPasswords: Bool = false, syncSSHProfiles: Bool = true, syncTableFavorites: Bool = true, + syncDatabaseFavorites: Bool = true, syncSQLFavorites: Bool = true ) { self.enabled = enabled @@ -35,6 +37,7 @@ struct SyncSettings: Codable, Equatable { self.syncPasswords = syncPasswords self.syncSSHProfiles = syncSSHProfiles self.syncTableFavorites = syncTableFavorites + self.syncDatabaseFavorites = syncDatabaseFavorites self.syncSQLFavorites = syncSQLFavorites } @@ -47,6 +50,7 @@ struct SyncSettings: Codable, Equatable { syncPasswords = try container.decodeIfPresent(Bool.self, forKey: .syncPasswords) ?? false syncSSHProfiles = try container.decodeIfPresent(Bool.self, forKey: .syncSSHProfiles) ?? true syncTableFavorites = try container.decodeIfPresent(Bool.self, forKey: .syncTableFavorites) ?? true + syncDatabaseFavorites = try container.decodeIfPresent(Bool.self, forKey: .syncDatabaseFavorites) ?? true syncSQLFavorites = try container.decodeIfPresent(Bool.self, forKey: .syncSQLFavorites) ?? true } @@ -58,6 +62,7 @@ struct SyncSettings: Codable, Equatable { syncPasswords: false, syncSSHProfiles: true, syncTableFavorites: true, + syncDatabaseFavorites: true, syncSQLFavorites: true ) } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 4be1d53c1..f25d2bd9b 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -12527,6 +12527,40 @@ } } }, + "All Environments" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모든 환경" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tüm Ortamlar" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tất cả môi trường" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "所有环境" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "所有環境" + } + } + } + }, "All Objects" : { "localizations" : { "ko" : { @@ -37964,6 +37998,40 @@ } } }, + "Development" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "개발" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geliştirme" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Phát triển" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "开发" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "開發" + } + } + } + }, "Diagnostic Info" : { "localizations" : { "ko" : { @@ -44219,6 +44287,40 @@ } } }, + "Environment" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "환경" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ortam" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Môi trường" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "环境" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "環境" + } + } + } + }, "Environment Variables" : { "localizations" : { "ko" : { @@ -49657,6 +49759,74 @@ } } }, + "Favorite" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "즐겨찾기" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sık Kullanılan" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yêu thích" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "收藏" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "我的最愛" + } + } + } + }, + "Favorite Database" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "즐겨찾는 데이터베이스" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sık Kullanılan Veritabanı" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cơ sở dữ liệu Yêu thích" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "收藏数据库" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "收藏資料庫" + } + } + } + }, "Favorited" : { "localizations" : { "ko" : { @@ -72554,6 +72724,40 @@ } } }, + "No Database Selected" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "선택된 데이터베이스 없음" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Veritabanı Seçilmedi" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chưa chọn cơ sở dữ liệu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未选择数据库" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "未選擇資料庫" + } + } + } + }, "No Databases" : { "localizations" : { "ko" : { @@ -74259,6 +74463,40 @@ } } }, + "No favorites match the selected environment." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "선택한 환경과 일치하는 즐겨찾기가 없습니다." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seçilen ortamla eşleşen sık kullanılan yok." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không có mục yêu thích nào khớp với môi trường đã chọn." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "没有收藏项与所选环境匹配。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "沒有收藏項目符合所選環境。" + } + } + } + }, "No filters applied" : { }, @@ -86163,6 +86401,40 @@ } } }, + "Production" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "프로덕션" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Üretim" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Production" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "生产" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "生產" + } + } + } + }, "Profile" : { "localizations" : { "en" : { @@ -113951,6 +114223,40 @@ } } }, + "Testing" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "테스트" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Test" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kiểm thử" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "测试" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "測試" + } + } + } + }, "Testing connection" : { "localizations" : { "ko" : { @@ -123025,6 +123331,40 @@ } } }, + "Unassigned" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "미지정" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atanmamış" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chưa gán" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未分配" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "未指派" + } + } + } + }, "Unchanged" : { "localizations" : { "ko" : { diff --git a/TablePro/ViewModels/FavoritesExpansionState.swift b/TablePro/ViewModels/FavoritesExpansionState.swift index 804231132..99aecb4f4 100644 --- a/TablePro/ViewModels/FavoritesExpansionState.swift +++ b/TablePro/ViewModels/FavoritesExpansionState.swift @@ -20,7 +20,10 @@ internal final class FavoritesExpansionState { @ObservationIgnored private let collapsedDatabaseEnvironmentsKey = "com.TablePro.favoritesCollapsedDatabaseEnvironments" - private init() { + @ObservationIgnored private let defaults: UserDefaults + + internal init(defaults: UserDefaults = AppStorageEnvironment.shared.defaults) { + self.defaults = defaults load() } @@ -92,38 +95,50 @@ internal final class FavoritesExpansionState { } private func load() { - if let data = AppStorageEnvironment.shared.defaults.data(forKey: foldersKey), + if let data = defaults.data(forKey: foldersKey), let decoded = try? JSONDecoder().decode([UUID: Set].self, from: data) { foldersByConnection = decoded } - if let data = AppStorageEnvironment.shared.defaults.data(forKey: linkedKey), + if let data = defaults.data(forKey: linkedKey), let decoded = try? JSONDecoder().decode([UUID: Set].self, from: data) { linkedNodesByConnection = decoded } - if let data = AppStorageEnvironment.shared.defaults.data(forKey: collapsedDatabaseEnvironmentsKey), - let decoded = try? JSONDecoder().decode( - [UUID: Set].self, - from: data - ) { - collapsedDatabaseEnvironmentsByConnection = decoded + collapsedDatabaseEnvironmentsByConnection = Self.decodeCollapsedEnvironments( + defaults.data(forKey: collapsedDatabaseEnvironmentsKey) + ) + } + + /// Decoded per raw value rather than whole. `Set` fails the entire + /// payload on one case this build does not know, which would silently discard the collapsed + /// state of every group of every connection, permanently, from the next write onward. + internal static func decodeCollapsedEnvironments( + _ data: Data? + ) -> [UUID: Set] { + guard let data, + let raw = try? JSONDecoder().decode([UUID: Set].self, from: data) + else { return [:] } + return raw.compactMapValues { values in + let environments = Set(values.compactMap(FavoriteDatabaseEnvironment.init(rawValue:))) + return environments.isEmpty ? nil : environments } } private func persistFolders() { if let data = try? JSONEncoder().encode(foldersByConnection) { - AppStorageEnvironment.shared.defaults.set(data, forKey: foldersKey) + defaults.set(data, forKey: foldersKey) } } private func persistLinkedNodes() { if let data = try? JSONEncoder().encode(linkedNodesByConnection) { - AppStorageEnvironment.shared.defaults.set(data, forKey: linkedKey) + defaults.set(data, forKey: linkedKey) } } private func persistCollapsedDatabaseEnvironments() { - if let data = try? JSONEncoder().encode(collapsedDatabaseEnvironmentsByConnection) { - AppStorageEnvironment.shared.defaults.set(data, forKey: collapsedDatabaseEnvironmentsKey) + let raw = collapsedDatabaseEnvironmentsByConnection.mapValues { Set($0.map(\.rawValue)) } + if let data = try? JSONEncoder().encode(raw) { + defaults.set(data, forKey: collapsedDatabaseEnvironmentsKey) } } } diff --git a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift index 3969d8411..07487702e 100644 --- a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift +++ b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift @@ -63,6 +63,7 @@ struct DatabaseSwitcherPopover: View { @Environment(\.dismiss) private var dismiss @State private var viewModel: DatabaseSwitcherViewModel @State private var supportsCreateDatabase = false + @State private var favoriteDatabases: Set = [] private static let popoverWidth: CGFloat = 320 private static let popoverHeight: CGFloat = 360 @@ -137,6 +138,10 @@ struct DatabaseSwitcherPopover: View { .background(refreshShortcut) .task { await viewModel.fetchDatabases() } .task { await refreshCreateSupport() } + .onAppear { reloadFavorites() } + .onReceive(NotificationCenter.default.publisher(for: .favoriteDatabasesDidChange)) { _ in + reloadFavorites() + } } private var refreshShortcut: some View { @@ -218,15 +223,30 @@ struct DatabaseSwitcherPopover: View { .truncationMode(.middle) Spacer(minLength: 0) + + favoriteIndicator(for: database) } .padding(.horizontal, 8) .contentShape(Rectangle()) } + /// A status glyph, not a control. `FieldDrivenCellHostingView` returns nil from `hitTest`, so a + /// button in this row could never be clicked; the right-click menu is where the toggle lives. + @ViewBuilder + private func favoriteIndicator(for database: DatabaseMetadata) -> some View { + let isFavorite = switchTarget == .database && favoriteDatabases.contains(database.name) + Image(systemName: "star.fill") + .font(.caption) + .selectionAwareTint(Color.yellow) + .opacity(isFavorite ? 1 : 0) + .accessibilityLabel(Text(String(localized: "Favorite"))) + .accessibilityHidden(!isFavorite) + } + private func contextMenuItems(for selection: Set) -> [FieldDrivenMenuItem] { let targets = containerRefs(for: selection) let droppable = ContainerDropEligibility.droppable(targets, context: dropEligibilityContext) - var items: [FieldDrivenMenuItem] = [] + var items: [FieldDrivenMenuItem] = favoriteItems(for: targets) if !targets.isEmpty { let copyTitle = targets.count == 1 @@ -260,6 +280,57 @@ struct DatabaseSwitcherPopover: View { return items } + /// Only in database mode. In schema mode these rows are schemas, and a schema name written into + /// the database favorites store is a favorite that names nothing. + private func reloadFavorites() { + guard switchTarget == .database else { return } + favoriteDatabases = Set( + FavoriteDatabasesStorage.shared.favorites(for: connectionId).map(\.database) + ) + } + + private func favoriteItems(for targets: [DatabaseContainerRef]) -> [FieldDrivenMenuItem] { + guard switchTarget == .database else { return [] } + let databases = targets.filter { $0.kind == .database }.compactMap(\.database) + guard !databases.isEmpty else { return [] } + + let stored = FavoriteDatabasesStorage.shared.favorites(for: connectionId) + let environments = Dictionary( + stored.map { ($0.database, $0.environment) }, + uniquingKeysWith: { first, _ in first } + ) + let state = FavoriteDatabaseSelectionState(environments: databases.map { environments[$0] }) + + var items: [FieldDrivenMenuItem] = [ + FieldDrivenMenuItem( + title: FavoriteDatabaseMenu.submenuTitle(for: state), + submenu: FavoriteDatabaseMenu.environmentItems(for: state).map { item in + FieldDrivenMenuItem(title: item.title, isOn: item.isOn) { + for database in databases { + FavoriteDatabasesStorage.shared.setFavorite( + database: database, + environment: item.environment, + connectionId: connectionId + ) + } + } + } + ) + ] + if state.hasFavorite { + items.append(FieldDrivenMenuItem(title: FavoriteDatabaseMenu.removeTitle) { + for database in databases { + FavoriteDatabasesStorage.shared.removeFavorite( + database: database, + connectionId: connectionId + ) + } + }) + } + items.append(.separator) + return items + } + /// A row's ref has to carry the kind the row actually is. Building a `.database` ref out of a /// schema name sends Drop to `dropDatabase` and gives Export a container it cannot resolve. private func containerRefs(for selection: Set) -> [DatabaseContainerRef] { diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 2be9c10a2..51b7bb7df 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -319,6 +319,38 @@ final class MainContentCommandActions { PluginManager.shared.supportsContainerSwitching(for: connection.type) } + /// An engine with no database dimension has nothing to favorite, and neither has a window whose + /// browse database is still empty. + var canFavoriteActiveDatabase: Bool { + PluginManager.shared.containerSwitchTarget(for: connection.type) == .database + && !browseDatabaseName.isEmpty + } + + var activeDatabaseFavoriteEnvironment: FavoriteDatabaseEnvironment? { + guard canFavoriteActiveDatabase else { return nil } + return FavoriteDatabasesStorage.shared + .favorites(for: connection.id) + .first { $0.database == browseDatabaseName }? + .environment + } + + func setActiveDatabaseFavorite(environment: FavoriteDatabaseEnvironment) { + guard canFavoriteActiveDatabase else { return } + FavoriteDatabasesStorage.shared.setFavorite( + database: browseDatabaseName, + environment: environment, + connectionId: connection.id + ) + } + + func removeActiveDatabaseFavorite() { + guard canFavoriteActiveDatabase else { return } + FavoriteDatabasesStorage.shared.removeFavorite( + database: browseDatabaseName, + connectionId: connection.id + ) + } + /// Picks between the two spellings a container command has. Each one is a whole localized /// string rather than a noun dropped into a format, because System Settings binds an App /// Shortcut to a menu item's exact literal title, and because the driver's own entity name diff --git a/TablePro/Views/Settings/Sections/SyncSection.swift b/TablePro/Views/Settings/Sections/SyncSection.swift index f102e00e3..feb01ac9a 100644 --- a/TablePro/Views/Settings/Sections/SyncSection.swift +++ b/TablePro/Views/Settings/Sections/SyncSection.swift @@ -122,6 +122,7 @@ struct SyncSection: View { Toggle("SSH Profiles:", isOn: $settingsManager.sync.syncSSHProfiles) Toggle("Settings:", isOn: $settingsManager.sync.syncSettings) Toggle("Table Favorites:", isOn: $settingsManager.sync.syncTableFavorites) + Toggle("Database Favorites:", isOn: $settingsManager.sync.syncDatabaseFavorites) Toggle("Saved Queries:", isOn: $settingsManager.sync.syncSQLFavorites) } } diff --git a/TablePro/Views/Shared/FieldDrivenList.swift b/TablePro/Views/Shared/FieldDrivenList.swift index 548e09c16..b91a368a7 100644 --- a/TablePro/Views/Shared/FieldDrivenList.swift +++ b/TablePro/Views/Shared/FieldDrivenList.swift @@ -22,19 +22,41 @@ internal struct FieldDrivenMenuItem { internal let title: String internal let isSeparator: Bool internal let isEnabled: Bool + /// Drawn as a checkmark. A menu that reports the current value is how a picker-shaped command + /// says which option is already chosen. + internal let isOn: Bool + internal let submenu: [FieldDrivenMenuItem] internal let action: () -> Void - internal init(title: String, isEnabled: Bool = true, action: @escaping () -> Void) { + internal init( + title: String, + isEnabled: Bool = true, + isOn: Bool = false, + action: @escaping () -> Void + ) { self.title = title self.isSeparator = false self.isEnabled = isEnabled + self.isOn = isOn + self.submenu = [] self.action = action } + internal init(title: String, submenu: [FieldDrivenMenuItem]) { + self.title = title + self.isSeparator = false + self.isEnabled = !submenu.isEmpty + self.isOn = false + self.submenu = submenu + self.action = {} + } + private init() { self.title = "" self.isSeparator = true self.isEnabled = false + self.isOn = false + self.submenu = [] self.action = {} } @@ -260,6 +282,10 @@ internal struct FieldDrivenList: NSViewRepresenta let targets = owner.selection.contains(id) ? owner.selection : [id] let descriptors = build(targets) guard !descriptors.isEmpty else { return nil } + return makeMenu(from: descriptors) + } + + private func makeMenu(from descriptors: [FieldDrivenMenuItem]) -> NSMenu { let menu = NSMenu() menu.autoenablesItems = false for descriptor in descriptors { @@ -267,9 +293,16 @@ internal struct FieldDrivenList: NSViewRepresenta menu.addItem(.separator()) continue } - let item = NSMenuItem(title: descriptor.title, action: #selector(performMenuItem(_:)), keyEquivalent: "") - item.target = self + let item = NSMenuItem(title: descriptor.title, action: nil, keyEquivalent: "") item.isEnabled = descriptor.isEnabled + guard descriptor.submenu.isEmpty else { + item.submenu = makeMenu(from: descriptor.submenu) + menu.addItem(item) + continue + } + item.action = #selector(performMenuItem(_:)) + item.target = self + item.state = descriptor.isOn ? .on : .off item.representedObject = MenuAction(descriptor.action) menu.addItem(item) } diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift index 001d2b9bf..743e24411 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift @@ -76,14 +76,18 @@ extension DatabaseTreeOutlineCoordinator { sidebarState?.clearRecentTables(inDatabase: mainCoordinator?.browseDatabaseName) case .useAsActive(let container): useAsActive(container) - case .setFavoriteDatabase(let database, let environment): - favoriteDatabasesStorage.setFavorite( - database: database, - environment: environment, - connectionId: connectionId - ) - case .removeFavoriteDatabase(let database): - favoriteDatabasesStorage.removeFavorite(database: database, connectionId: connectionId) + case .setFavoriteDatabases(let databases, let environment): + for database in databases { + favoriteDatabasesStorage.setFavorite( + database: database, + environment: environment, + connectionId: connectionId + ) + } + case .removeFavoriteDatabases(let databases): + for database in databases { + favoriteDatabasesStorage.removeFavorite(database: database, connectionId: connectionId) + } case .refreshContainers(let targets): refreshContainers(targets) case .copyContainerNames(let targets): diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift index 5bab0996a..a2fafa611 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift @@ -33,12 +33,6 @@ extension DatabaseTreeOutlineCoordinator: NSMenuDelegate { private func menuContext() -> DatabaseTreeMenuContext { let clicked = clickedNode() let clickedRef = clicked.flatMap(DatabaseTreeSelection.tableRef) - let favoriteDatabaseEnvironment: FavoriteDatabaseEnvironment? - if case .database(let metadata) = clicked?.kind { - favoriteDatabaseEnvironment = self.favoriteDatabaseEnvironment(for: metadata.name) - } else { - favoriteDatabaseEnvironment = nil - } let settings = AppSettingsManager.shared.general return DatabaseTreeMenuContext( clicked: clicked?.kind, @@ -65,7 +59,7 @@ extension DatabaseTreeOutlineCoordinator: NSMenuDelegate { schemaEntityNamePlural: PluginManager.shared.schemaEntityNamePlural(for: databaseType), objectKindTitles: objectKindTitles(), isFavorite: clickedRef.map { isFavorite($0) } ?? false, - favoriteDatabaseEnvironment: favoriteDatabaseEnvironment, + favoriteDatabaseEnvironments: favoriteDatabaseEnvironments(), showObjectIcons: settings.showObjectIcons, showObjectComments: settings.showObjectComments, rowSize: settings.sidebarRowSize, diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift index 5dd394ce1..e10309412 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift @@ -59,23 +59,9 @@ final class DatabaseTreeOutlineCoordinator: NSObject { private var tableFavoritesObserver: (any NSObjectProtocol)? private var databaseFavoritesObserver: (any NSObjectProtocol)? - override convenience init() { - self.init( - favoriteTablesStorage: .shared, - favoriteDatabasesStorage: .shared - ) - } - - convenience init(favoriteTablesStorage: FavoriteTablesStorage) { - self.init( - favoriteTablesStorage: favoriteTablesStorage, - favoriteDatabasesStorage: .shared - ) - } - init( - favoriteTablesStorage: FavoriteTablesStorage, - favoriteDatabasesStorage: FavoriteDatabasesStorage + favoriteTablesStorage: FavoriteTablesStorage = .shared, + favoriteDatabasesStorage: FavoriteDatabasesStorage = .shared ) { self.favoriteTablesStorage = favoriteTablesStorage self.favoriteDatabasesStorage = favoriteDatabasesStorage @@ -291,15 +277,27 @@ final class DatabaseTreeOutlineCoordinator: NSObject { private func favoriteState(for node: DatabaseTreeNode) -> Bool { switch node.kind { case .database(let metadata): - return favoriteDatabaseEnvironment(for: metadata.name) != nil + return favoriteDatabases.contains { $0.database == metadata.name } default: guard let ref = DatabaseTreeSelection.tableRef(of: node) else { return false } return isFavorite(ref) } } - internal func favoriteDatabaseEnvironment(for database: String) -> FavoriteDatabaseEnvironment? { - favoriteDatabases.first { $0.database == database }?.environment + internal func favoriteDatabaseEnvironments() -> [String: FavoriteDatabaseEnvironment] { + Dictionary(favoriteDatabases.map { ($0.database, $0.environment) }) { first, _ in first } + } + + internal func toggleFavoriteDatabase(_ database: String) { + guard favoriteDatabases.contains(where: { $0.database == database }) else { + favoriteDatabasesStorage.setFavorite( + database: database, + environment: .unassigned, + connectionId: connectionId + ) + return + } + favoriteDatabasesStorage.removeFavorite(database: database, connectionId: connectionId) } internal func toggleFavorite(_ ref: DatabaseTreeTableRef) { @@ -510,7 +508,8 @@ final class DatabaseTreeOutlineCoordinator: NSObject { private func makeRowActions() -> DatabaseTreeRowActions { DatabaseTreeRowActions( - toggleFavorite: { [weak self] ref in self?.toggleFavorite(ref) } + toggleFavorite: { [weak self] ref in self?.toggleFavorite(ref) }, + toggleFavoriteDatabase: { [weak self] database in self?.toggleFavoriteDatabase(database) } ) } diff --git a/TablePro/Views/Sidebar/DatabaseTreeRowView.swift b/TablePro/Views/Sidebar/DatabaseTreeRowView.swift index 37d19f3b1..64f014b43 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeRowView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeRowView.swift @@ -10,6 +10,16 @@ import TableProPluginKit /// `SidebarMenuCommand`, so this is only the star, which is a control inside the row. struct DatabaseTreeRowActions { let toggleFavorite: (DatabaseTreeTableRef) -> Void + let toggleFavoriteDatabase: (String) -> Void +} + +/// Row labels are built here rather than inline so the database row reads to VoiceOver in the same +/// shape `TableRowLogic` already uses for a table, instead of inventing a second phrasing. +enum DatabaseTreeRowLabel { + static func database(name: String, isFavorite: Bool) -> String { + guard isFavorite else { return name } + return name + ", " + String(localized: "favorite") + } } struct DatabaseTreeRowContext { @@ -34,6 +44,8 @@ struct DatabaseTreeRowView: View { let context: DatabaseTreeRowContext let actions: DatabaseTreeRowActions + @State private var isHovered = false + /// No `.contextMenu` here. A menu on the hosted view answers the right-click before the outline /// view ever sees it, which cost the clicked-row highlight, `clickedRow`, and any menu at all in /// the empty area below the last row. The outline owns the menu now; see @@ -108,7 +120,8 @@ struct DatabaseTreeRowView: View { } private func databaseRow(_ metadata: DatabaseMetadata) -> some View { - HStack(spacing: 6) { + let toggle = { actions.toggleFavoriteDatabase(metadata.name) } + return HStack(spacing: 6) { header( text: metadata.name, systemImage: metadata.isSystemDatabase ? "gearshape" : "cylinder", @@ -116,17 +129,12 @@ struct DatabaseTreeRowView: View { isSystem: metadata.isSystemDatabase ) Spacer(minLength: 4) - if isFavorite { - Image(systemName: "star.fill") - .foregroundStyle(.yellow) - .accessibilityHidden(true) - } + FavoriteStarButton(isFavorite: isFavorite, isRowHovered: isHovered, toggle: toggle) } - .accessibilityLabel( - isFavorite - ? String(format: String(localized: "%@, Favorite"), metadata.name) - : metadata.name - ) + .onHover { isHovered = $0 } + .accessibilityElement(children: .combine) + .accessibilityLabel(DatabaseTreeRowLabel.database(name: metadata.name, isFavorite: isFavorite)) + .modifier(FavoriteAccessibilityAction(isFavorite: isFavorite, toggle: toggle)) } private func tableRow(_ ref: DatabaseTreeTableRef) -> some View { diff --git a/TablePro/Views/Sidebar/FavoriteStarButton.swift b/TablePro/Views/Sidebar/FavoriteStarButton.swift new file mode 100644 index 000000000..fa2de4def --- /dev/null +++ b/TablePro/Views/Sidebar/FavoriteStarButton.swift @@ -0,0 +1,55 @@ +// +// FavoriteStarButton.swift +// TablePro +// + +import SwiftUI + +/// The favorite star every sidebar row uses. +/// +/// It is hidden until the row is hovered unless the object is already a favorite, so a list of +/// non-favorites is not a column of grey stars. The button itself is hidden from VoiceOver because +/// a row reads as one element; `FavoriteAccessibilityAction` on the row is what makes it reachable. +internal struct FavoriteStarButton: View { + internal let isFavorite: Bool + internal let isRowHovered: Bool + internal let toggle: () -> Void + + private var isVisible: Bool { isFavorite || isRowHovered } + + internal var body: some View { + Button(action: toggle) { + Image(systemName: isFavorite ? "star.fill" : "star") + .font(.system(size: 11, weight: .regular)) + .selectionAwareTint(isFavorite ? Color.yellow : Color.secondary) + .frame(width: 20, height: 20) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .opacity(isVisible ? 1 : 0) + .allowsHitTesting(isVisible) + .accessibilityHidden(true) + .help(isFavorite + ? String(localized: "Remove from Favorites") + : String(localized: "Add to Favorites")) + } +} + +/// A hover-revealed control is pointer-only, so the row publishes the same action to VoiceOver. +internal struct FavoriteAccessibilityAction: ViewModifier { + internal let isFavorite: Bool + internal let toggle: (() -> Void)? + + internal func body(content: Content) -> some View { + if let toggle { + content.accessibilityAction( + named: isFavorite + ? Text("Remove from Favorites") + : Text("Add to Favorites"), + toggle + ) + } else { + content + } + } +} diff --git a/TablePro/Views/Sidebar/FavoritesEmptyState.swift b/TablePro/Views/Sidebar/FavoritesEmptyState.swift new file mode 100644 index 000000000..87cdaa7bb --- /dev/null +++ b/TablePro/Views/Sidebar/FavoritesEmptyState.swift @@ -0,0 +1,51 @@ +// +// FavoritesEmptyState.swift +// TablePro +// + +import Foundation + +/// Which of the four states the Favorites tab is in. +/// +/// The tab used to decide this inline, and read a list emptied by the environment filter as a +/// failed search: `ContentUnavailableView.search(text:)` renders "No Results for “”" over spelling +/// advice for a query the user never typed. A filter miss is a different state with a different +/// cause and gets its own. +internal enum FavoritesEmptyState: Equatable { + case loading + case noFavorites + case noFilterMatch + case noSearchMatch(String) + case content + + internal struct Input { + internal let isInitialLoadComplete: Bool + internal let hasAnyFavorite: Bool + internal let hasVisibleContent: Bool + internal let searchText: String + internal let isEnvironmentFiltered: Bool + + internal init( + isInitialLoadComplete: Bool, + hasAnyFavorite: Bool, + hasVisibleContent: Bool, + searchText: String, + isEnvironmentFiltered: Bool + ) { + self.isInitialLoadComplete = isInitialLoadComplete + self.hasAnyFavorite = hasAnyFavorite + self.hasVisibleContent = hasVisibleContent + self.searchText = searchText + self.isEnvironmentFiltered = isEnvironmentFiltered + } + } + + internal static func resolve(_ input: Input) -> FavoritesEmptyState { + if input.hasVisibleContent { return .content } + if !input.isInitialLoadComplete && !input.hasAnyFavorite { return .loading } + if !input.hasAnyFavorite { return .noFavorites } + if !input.searchText.isEmpty { return .noSearchMatch(input.searchText) } + if input.isEnvironmentFiltered { return .noFilterMatch } + return .noFavorites + } +} diff --git a/TablePro/Views/Sidebar/FavoritesOutlineCoordinator.swift b/TablePro/Views/Sidebar/FavoritesOutlineCoordinator.swift index 250756b9b..82ef1aa89 100644 --- a/TablePro/Views/Sidebar/FavoritesOutlineCoordinator.swift +++ b/TablePro/Views/Sidebar/FavoritesOutlineCoordinator.swift @@ -54,7 +54,7 @@ internal final class FavoritesOutlineCoordinator: NSObject, NSOutline /// outline reloads only when the set of rows or their nesting changed. Depth is part of the /// fingerprint because moving a favorite into a folder can leave the pre-order id list identical. private static func fingerprint(of input: FavoritesOutlineInput) -> String { - var parts: [String] = [input.activeDatabase ?? "", input.isFilteringDatabases ? "filtering" : ""] + var parts: [String] = [input.activeDatabase ?? "", input.isNarrowingDatabases ? "filtering" : ""] parts += input.databaseGroups.flatMap { group in ["environment|\(group.environment.rawValue)"] + group.entries.map { "\(group.environment.rawValue)|\($0.id)" } @@ -191,7 +191,7 @@ internal final class FavoritesOutlineCoordinator: NSObject, NSOutline let shouldExpand: Bool switch node.kind { case .databaseEnvironment(let group): - shouldExpand = owner.input.isFilteringDatabases || FavoritesExpansion + shouldExpand = owner.input.isNarrowingDatabases || FavoritesExpansion .isDatabaseEnvironmentExpanded(group.environment, connectionId: owner.input.connectionId) case .query(let favoriteNode): shouldExpand = FavoritesExpansion.isExpanded( @@ -223,7 +223,7 @@ internal final class FavoritesOutlineCoordinator: NSObject, NSOutline let node = notification.userInfo?["NSObject"] as? FavoritesOutlineNode else { return } switch node.kind { case .databaseEnvironment(let group): - guard !owner.input.isFilteringDatabases else { return } + guard !owner.input.isNarrowingDatabases else { return } FavoritesExpansion.setDatabaseEnvironmentExpanded( group.environment, expanded: expanded, diff --git a/TablePro/Views/Sidebar/FavoritesOutlineView.swift b/TablePro/Views/Sidebar/FavoritesOutlineView.swift index c6a6eae4c..dafc557b6 100644 --- a/TablePro/Views/Sidebar/FavoritesOutlineView.swift +++ b/TablePro/Views/Sidebar/FavoritesOutlineView.swift @@ -14,7 +14,7 @@ internal struct FavoritesOutlineInput { internal let databaseGroups: [FavoriteDatabaseGroup] internal let databaseEntityName: String internal let databaseEntityNamePlural: String - internal let isFilteringDatabases: Bool + internal let isNarrowingDatabases: Bool internal let tables: [TableInfo] internal let queryNodes: [FavoriteNode] internal let teamQueries: [FavoritesOutlineTeamQuery] diff --git a/TablePro/Views/Sidebar/FavoritesTabView.swift b/TablePro/Views/Sidebar/FavoritesTabView.swift index 69c5af7ca..de8d3fe2b 100644 --- a/TablePro/Views/Sidebar/FavoritesTabView.swift +++ b/TablePro/Views/Sidebar/FavoritesTabView.swift @@ -86,24 +86,29 @@ internal struct FavoritesTabView: View { ? availableFavoriteTables : availableFavoriteTables.filter { $0.name.localizedCaseInsensitiveContains(searchText) } - if !viewModel.isInitialLoadComplete - && viewModel.nodes.isEmpty - && filteredTables.isEmpty - && favoriteDatabases.isEmpty { + switch FavoritesEmptyState.resolve(FavoritesEmptyState.Input( + isInitialLoadComplete: viewModel.isInitialLoadComplete, + hasAnyFavorite: !viewModel.nodes.isEmpty + || !availableFavoriteTables.isEmpty + || !teamLibraryQueries.isEmpty + || !favoriteDatabases.isEmpty, + hasVisibleContent: !items.isEmpty + || !groups.isEmpty + || !filteredTables.isEmpty + || !teamLibraryQueries.isEmpty, + searchText: searchText, + isEnvironmentFiltered: sharedSidebarState.favoriteDatabaseEnvironmentFilter != .all + )) { + case .loading: ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if viewModel.nodes.isEmpty - && filteredTables.isEmpty - && teamLibraryQueries.isEmpty - && favoriteDatabases.isEmpty - && searchText.isEmpty { + case .noFavorites: emptyState - } else if items.isEmpty - && groups.isEmpty - && filteredTables.isEmpty - && teamLibraryQueries.isEmpty { - noMatchState - } else { + case .noFilterMatch: + noFilterMatchState + case .noSearchMatch(let term): + noSearchMatchState(term) + case .content: favoritesList(items, databaseGroups: groups, filteredTables: filteredTables) } } @@ -274,7 +279,8 @@ internal struct FavoritesTabView: View { databaseGroups: databaseGroups, databaseEntityName: databaseEntityName, databaseEntityNamePlural: databaseEntityNamePlural, - isFilteringDatabases: !searchText.isEmpty, + isNarrowingDatabases: !searchText.isEmpty + || sharedSidebarState.favoriteDatabaseEnvironmentFilter != .all, tables: filteredTables, queryNodes: items, teamQueries: teamLibraryQueries.map { @@ -360,7 +366,7 @@ internal struct FavoritesTabView: View { .sidebarRowIcon(visible: AppSettingsManager.shared.general.showObjectIcons) .lineLimit(1) .accessibilityLabel(String( - format: String(localized: "Favorite %@, %@"), + format: String(localized: "%@: %@"), databaseEntityName, entry.database )) @@ -602,11 +608,22 @@ internal struct FavoritesTabView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } - private var noMatchState: some View { - ContentUnavailableView.search(text: searchText) + private func noSearchMatchState(_ term: String) -> some View { + ContentUnavailableView.search(text: term) .frame(maxWidth: .infinity, maxHeight: .infinity) } + /// A filter miss is not a failed search, so it never borrows the search placeholder's "check + /// the spelling" advice. The filter control stays on screen above this, which is the reset. + private var noFilterMatchState: some View { + ContentUnavailableView { + Label(String(localized: "No Matching Favorites"), systemImage: "line.3.horizontal.decrease.circle") + } description: { + Text("No favorites match the selected environment.") + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + private func addLinkedFolder() { let panel = NSOpenPanel() panel.canChooseFiles = false diff --git a/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift b/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift index 099d8f44f..35318a90c 100644 --- a/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift +++ b/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift @@ -31,7 +31,9 @@ internal struct DatabaseTreeMenuContext { internal let schemaEntityNamePlural: String internal let objectKindTitles: [SidebarObjectKind: String] internal let isFavorite: Bool - internal let favoriteDatabaseEnvironment: FavoriteDatabaseEnvironment? + /// Keyed per database rather than resolved for the clicked row alone, because a right-click + /// inside a multi-selection acts on the whole selection and those databases need not share a tag. + internal var favoriteDatabaseEnvironments: [String: FavoriteDatabaseEnvironment] = [:] internal let showObjectIcons: Bool internal let showObjectComments: Bool internal let rowSize: SidebarRowSizePreference @@ -220,12 +222,18 @@ internal enum DatabaseTreeMenuSpec { items.append(.command(String(localized: "Refresh"), .refreshContainers(targets))) items.append(.command(copyNamesTitle(count: targets.count), .copyContainerNames(targets))) - if targets.count == 1, clicked.kind == .database { - items.append(.separator) - items += favoriteDatabaseItems( - database: clicked.database, - currentEnvironment: context.favoriteDatabaseEnvironment + let favoriteDatabases = targets.filter { $0.kind == .database }.compactMap(\.database) + if !favoriteDatabases.isEmpty { + let favoriteItems = favoriteDatabaseItems( + databases: favoriteDatabases, + state: FavoriteDatabaseSelectionState( + environments: favoriteDatabases.map { context.favoriteDatabaseEnvironments[$0] } + ) ) + if !favoriteItems.isEmpty { + items.append(.separator) + items += favoriteItems + } } if ExportPreselection.canPreselect( @@ -243,23 +251,25 @@ internal enum DatabaseTreeMenuSpec { } private static func favoriteDatabaseItems( - database: String, - currentEnvironment: FavoriteDatabaseEnvironment? + databases: [String], + state: FavoriteDatabaseSelectionState ) -> [DatabaseTreeMenuItem] { - let environmentItems: [DatabaseTreeMenuItem] = FavoriteDatabaseEnvironment.allCases.map { environment in - .command(SidebarMenuEntry( - title: environment.menuTitle, - command: .setFavoriteDatabase(database: database, environment: environment), - isOn: currentEnvironment == environment - )) - } - guard currentEnvironment != nil else { - return [.submenu(title: String(localized: "Add to Favorites"), items: environmentItems)] - } - return [ - .submenu(title: String(localized: "Environment"), items: environmentItems), - .destructive(String(localized: "Remove from Favorites"), .removeFavoriteDatabase(database)) + guard !state.isEmpty else { return [] } + let environmentItems: [DatabaseTreeMenuItem] = FavoriteDatabaseMenu.environmentItems(for: state) + .map { item in + .command(SidebarMenuEntry( + title: item.title, + command: .setFavoriteDatabases(databases: databases, environment: item.environment), + isOn: item.isOn + )) + } + var items: [DatabaseTreeMenuItem] = [ + .submenu(title: FavoriteDatabaseMenu.submenuTitle(for: state), items: environmentItems) ] + if state.hasFavorite { + items.append(.destructive(FavoriteDatabaseMenu.removeTitle, .removeFavoriteDatabases(databases))) + } + return items } private static func isActive(_ container: DatabaseContainerRef, context: DatabaseTreeMenuContext) -> Bool { diff --git a/TablePro/Views/Sidebar/Menu/FavoriteDatabaseMenu.swift b/TablePro/Views/Sidebar/Menu/FavoriteDatabaseMenu.swift new file mode 100644 index 000000000..7028a21c4 --- /dev/null +++ b/TablePro/Views/Sidebar/Menu/FavoriteDatabaseMenu.swift @@ -0,0 +1,59 @@ +// +// FavoriteDatabaseMenu.swift +// TablePro +// + +import Foundation + +/// What the favorite items look like for one right-click, as values. +/// +/// A click can carry several databases, and they need not agree: some may be favorites already, and +/// the ones that are may be tagged differently. Resolving that once here keeps the object tree, the +/// database switcher, the Favorites tab and the Database menu showing the same thing. +internal struct FavoriteDatabaseSelectionState: Equatable { + internal let targetCount: Int + internal let favoriteCount: Int + /// The environment every favorite in the selection shares, or nil when they disagree. + internal let sharedEnvironment: FavoriteDatabaseEnvironment? + + internal init(environments: [FavoriteDatabaseEnvironment?]) { + targetCount = environments.count + let assigned = environments.compactMap { $0 } + favoriteCount = assigned.count + let distinct = Set(assigned) + sharedEnvironment = distinct.count == 1 ? distinct.first : nil + } + + internal var isEmpty: Bool { targetCount == 0 } + /// Retagging rather than adding, so the submenu names the attribute instead of the action. + internal var isEntirelyFavorite: Bool { favoriteCount == targetCount && targetCount > 0 } + internal var hasFavorite: Bool { favoriteCount > 0 } +} + +internal enum FavoriteDatabaseMenu { + internal struct EnvironmentItem: Equatable { + internal let environment: FavoriteDatabaseEnvironment + internal let title: String + internal let isOn: Bool + } + + internal static func submenuTitle(for state: FavoriteDatabaseSelectionState) -> String { + state.isEntirelyFavorite + ? String(localized: "Environment") + : String(localized: "Add to Favorites") + } + + internal static var removeTitle: String { + String(localized: "Remove from Favorites") + } + + internal static func environmentItems(for state: FavoriteDatabaseSelectionState) -> [EnvironmentItem] { + FavoriteDatabaseEnvironment.allCases.map { environment in + EnvironmentItem( + environment: environment, + title: environment.title, + isOn: state.isEntirelyFavorite && state.sharedEnvironment == environment + ) + } + } +} diff --git a/TablePro/Views/Sidebar/Menu/FavoritesMenuSpec.swift b/TablePro/Views/Sidebar/Menu/FavoritesMenuSpec.swift index 3234d31d3..8c1a0d2cb 100644 --- a/TablePro/Views/Sidebar/Menu/FavoritesMenuSpec.swift +++ b/TablePro/Views/Sidebar/Menu/FavoritesMenuSpec.swift @@ -63,21 +63,19 @@ internal enum FavoritesMenuSpec { .useDatabase(entry) )) } + let state = FavoriteDatabaseSelectionState(environments: [entry.environment]) items.append(.submenu( - title: String(localized: "Environment"), - items: FavoriteDatabaseEnvironment.allCases.map { environment in + title: FavoriteDatabaseMenu.submenuTitle(for: state), + items: FavoriteDatabaseMenu.environmentItems(for: state).map { item in .command(SidebarMenuEntry( - title: environment.menuTitle, - command: .setDatabaseEnvironment(entry, environment), - isOn: entry.environment == environment + title: item.title, + command: .setDatabaseEnvironment(entry, item.environment), + isOn: item.isOn )) } )) items.append(.separator) - items.append(.destructive( - String(localized: "Remove from Favorites"), - .removeDatabaseFavorite(entry) - )) + items.append(.destructive(FavoriteDatabaseMenu.removeTitle, .removeDatabaseFavorite(entry))) return items } diff --git a/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift b/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift index d5eddd3d1..9b182cc1b 100644 --- a/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift +++ b/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift @@ -35,8 +35,8 @@ internal enum SidebarMenuCommand: Equatable { case removeRecent(DatabaseTreeTableRef) case clearRecents case useAsActive(DatabaseContainerRef) - case setFavoriteDatabase(database: String, environment: FavoriteDatabaseEnvironment) - case removeFavoriteDatabase(String) + case setFavoriteDatabases(databases: [String], environment: FavoriteDatabaseEnvironment) + case removeFavoriteDatabases([String]) case refreshContainers([DatabaseContainerRef]) case copyContainerNames([DatabaseContainerRef]) case exportContainers([DatabaseContainerRef]) diff --git a/TablePro/Views/Sidebar/SidebarPersistenceKey.swift b/TablePro/Views/Sidebar/SidebarPersistenceKey.swift index 5ea0ed005..8233ac1a2 100644 --- a/TablePro/Views/Sidebar/SidebarPersistenceKey.swift +++ b/TablePro/Views/Sidebar/SidebarPersistenceKey.swift @@ -37,4 +37,27 @@ enum SidebarPersistenceKey { static func expanded(connectionId: UUID, kind: SidebarObjectKind) -> String { "sidebar.\(connectionId.uuidString).\(kind.rawValue).expanded" } + + /// Every per-connection key this type can produce. A key added above and forgotten here outlives + /// the connection it belongs to for the life of the install. + static func all(connectionId: UUID) -> [String] { + [ + tablesExpanded(connectionId: connectionId), + redisKeysExpanded(connectionId: connectionId), + recentsExpanded(connectionId: connectionId), + selectedTab(connectionId: connectionId), + selectedFavorite(connectionId: connectionId), + favoriteDatabaseEnvironmentFilter(connectionId: connectionId), + layout(connectionId: connectionId) + ] + SidebarObjectKind.allCases.map { expanded(connectionId: connectionId, kind: $0) } + } + + static func removeAll( + connectionId: UUID, + defaults: UserDefaults = AppStorageEnvironment.shared.defaults + ) { + for key in all(connectionId: connectionId) { + defaults.removeObject(forKey: key) + } + } } diff --git a/TablePro/Views/Sidebar/TableRowView.swift b/TablePro/Views/Sidebar/TableRowView.swift index 8f0693205..8340ff78e 100644 --- a/TablePro/Views/Sidebar/TableRowView.swift +++ b/TablePro/Views/Sidebar/TableRowView.swift @@ -123,21 +123,11 @@ struct TableRow: View { Spacer(minLength: 4) if let onToggleFavorite { - let starVisible = isFavorite || isHovered - Button(action: onToggleFavorite) { - Image(systemName: isFavorite ? "star.fill" : "star") - .font(.system(size: 11, weight: .regular)) - .selectionAwareTint(isFavorite ? Color.yellow : Color.secondary) - .frame(width: 20, height: 20) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .opacity(starVisible ? 1 : 0) - .allowsHitTesting(starVisible) - .accessibilityHidden(true) - .help(isFavorite - ? String(localized: "Remove from Favorites") - : String(localized: "Add to Favorites")) + FavoriteStarButton( + isFavorite: isFavorite, + isRowHovered: isHovered, + toggle: onToggleFavorite + ) } } .onHover { isHovered = $0 } @@ -153,21 +143,3 @@ struct TableRow: View { .modifier(FavoriteAccessibilityAction(isFavorite: isFavorite, toggle: onToggleFavorite)) } } - -private struct FavoriteAccessibilityAction: ViewModifier { - let isFavorite: Bool - let toggle: (() -> Void)? - - func body(content: Content) -> some View { - if let toggle { - content.accessibilityAction( - named: isFavorite - ? Text("Remove from Favorites") - : Text("Add to Favorites"), - toggle - ) - } else { - content - } - } -} diff --git a/TableProTests/Core/Menu/MainMenuBuilderTests.swift b/TableProTests/Core/Menu/MainMenuBuilderTests.swift index 3a8d19e4f..e00790525 100644 --- a/TableProTests/Core/Menu/MainMenuBuilderTests.swift +++ b/TableProTests/Core/Menu/MainMenuBuilderTests.swift @@ -479,6 +479,7 @@ struct DatabaseMenuCommandTests { String(localized: "Show Table Structure"), String(localized: "Edit View Definition..."), String(localized: "Table Maintenance"), + String(localized: "Favorite Database"), String(localized: "Disconnect"), String(localized: "Reconnect") ] { @@ -494,6 +495,16 @@ struct DatabaseMenuCommandTests { #expect(submenu?.items.isEmpty == true, "The submenu is filled when it opens, not at build time") } + /// The keyboard path to database favorites. The row star is hover-revealed and the context + /// menus need a right-click, so without this menu the feature is pointer-only. + @Test("Favorite Database fills itself when the submenu opens") + func favoriteDatabaseSubmenuIsDelegateDriven() { + let container = databaseMenu()?.items.first { $0.title == String(localized: "Favorite Database") } + let submenu = container?.submenu + #expect(submenu?.delegate != nil, "The current environment must be read on menuNeedsUpdate") + #expect(submenu?.items.isEmpty == true, "The submenu is filled when it opens, not at build time") + } + @Test("Disconnect and Reconnect route through the responder chain") func connectionCommandsUseTheResponderChain() { let items = (databaseMenu()?.items ?? []).filter { diff --git a/TableProTests/Core/Storage/FavoriteDatabasesStorageTests.swift b/TableProTests/Core/Storage/FavoriteDatabasesStorageTests.swift index fa4dcfd1f..320d506ad 100644 --- a/TableProTests/Core/Storage/FavoriteDatabasesStorageTests.swift +++ b/TableProTests/Core/Storage/FavoriteDatabasesStorageTests.swift @@ -5,22 +5,38 @@ import Foundation import Testing +import TableProSyncTransport @testable import TablePro @MainActor @Suite("FavoriteDatabasesStorage") struct FavoriteDatabasesStorageTests { - private func makeStorage() throws -> (FavoriteDatabasesStorage, UserDefaults) { - let suite = "FavoriteDatabasesStorageTests.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - return (FavoriteDatabasesStorage(defaults: defaults), defaults) + private static let storageKey = "com.TablePro.favoriteDatabases" + + private func makeStorage() throws -> (FavoriteDatabasesStorage, UserDefaults, SyncMetadataStorage) { + let favoritesSuite = "FavoriteDatabasesStorageTests.favorites.\(UUID().uuidString)" + let syncSuite = "FavoriteDatabasesStorageTests.sync.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: favoritesSuite)) + let syncDefaults = try #require(UserDefaults(suiteName: syncSuite)) + defaults.removePersistentDomain(forName: favoritesSuite) + syncDefaults.removePersistentDomain(forName: syncSuite) + + let metadata = SyncMetadataStorage(userDefaults: syncDefaults) + let storage = FavoriteDatabasesStorage( + defaults: defaults, + syncTracker: SyncChangeTracker(metadataStorage: metadata) + ) + return (storage, defaults, metadata) + } + + private func store(_ json: String, in defaults: UserDefaults) { + defaults.set(Data(json.utf8), forKey: Self.storageKey) } @Test("Favorite identity includes the connection") func favoritesAreConnectionScoped() throws { - let (storage, _) = try makeStorage() + let (storage, _, _) = try makeStorage() let first = UUID() let second = UUID() @@ -33,7 +49,7 @@ struct FavoriteDatabasesStorageTests { @Test("Changing an environment replaces the favorite instead of duplicating it") func environmentUpdateReplacesEntry() throws { - let (storage, _) = try makeStorage() + let (storage, _, _) = try makeStorage() let connectionId = UUID() storage.setFavorite(database: "orders", environment: .development, connectionId: connectionId) @@ -46,7 +62,7 @@ struct FavoriteDatabasesStorageTests { @Test("Removing one database preserves the connection's other favorites") func removePreservesOtherEntries() throws { - let (storage, _) = try makeStorage() + let (storage, _, _) = try makeStorage() let connectionId = UUID() storage.setFavorite(database: "app", environment: .development, connectionId: connectionId) storage.setFavorite(database: "audit", environment: .testing, connectionId: connectionId) @@ -58,59 +74,194 @@ struct FavoriteDatabasesStorageTests { @Test("An unknown stored environment falls back to Unassigned") func unknownEnvironmentFallsBack() throws { - let (storage, defaults) = try makeStorage() + let (storage, defaults, _) = try makeStorage() let connectionId = UUID() - let json = """ - [{"connectionId":"\(connectionId.uuidString)","database":"future","environment":"staging"}] - """ - defaults.set( - Data(json.utf8), - forKey: "com.TablePro.favoriteDatabases.\(connectionId.uuidString)" + store( + """ + [{"connectionId":"\(connectionId.uuidString)","database":"future","environment":"staging"}] + """, + in: defaults + ) + + #expect(storage.favorites(for: connectionId).first?.environment == .unassigned) + } + + @Test("Malformed storage reads as no favorites rather than crashing") + func malformedStorageReturnsEmpty() throws { + let (storage, defaults, _) = try makeStorage() + store("not-json", in: defaults) + + #expect(storage.loadFavorites().isEmpty) + } + + @Test("A favorite belongs only to the connection it names") + func ignoresEntriesFromAnotherConnection() throws { + let (storage, defaults, _) = try makeStorage() + let requested = UUID() + let foreign = UUID() + store( + """ + [{"connectionId":"\(foreign.uuidString)","database":"private","environment":"production"}] + """, + in: defaults + ) + + #expect(storage.favorites(for: requested).isEmpty) + #expect(storage.favorites(for: foreign).map(\.database) == ["private"]) + } + + @Test("Deleting a connection removes its favorites and leaves the others alone") + func removeConnectionFavorites() throws { + let (storage, _, _) = try makeStorage() + let deleted = UUID() + let kept = UUID() + storage.setFavorite(database: "app", environment: .production, connectionId: deleted) + storage.setFavorite(database: "app", environment: .production, connectionId: kept) + + storage.removeFavorites(for: deleted) + + #expect(storage.favorites(for: deleted).isEmpty) + #expect(storage.favorites(for: kept).map(\.database) == ["app"]) + } + + // MARK: - Change notification + + @Test("Re-picking the environment a database already has changes nothing") + func settingTheSameEnvironmentIsANoOp() throws { + let (storage, _, metadata) = try makeStorage() + let connectionId = UUID() + storage.setFavorite(database: "app", environment: .production, connectionId: connectionId) + metadata.clearDirty(type: .favoriteDatabase) + + var notifications = 0 + let observer = NotificationCenter.default.addObserver( + forName: .favoriteDatabasesDidChange, object: nil, queue: nil + ) { _ in notifications += 1 } + defer { NotificationCenter.default.removeObserver(observer) } + + storage.setFavorite(database: "app", environment: .production, connectionId: connectionId) + + #expect(notifications == 0) + #expect(metadata.dirtyIds(for: .favoriteDatabase).isEmpty) + } + + @Test("Removing a database that is not a favorite changes nothing") + func removingAnAbsentFavoriteIsANoOp() throws { + let (storage, _, metadata) = try makeStorage() + let connectionId = UUID() + + storage.removeFavorite(database: "missing", connectionId: connectionId) + + #expect(metadata.tombstones(for: .favoriteDatabase).isEmpty) + } + + // MARK: - Sync + + @Test("Adding a favorite marks its sync id dirty") + func addMarksDirty() throws { + let (storage, _, metadata) = try makeStorage() + let connectionId = UUID() + storage.setFavorite(database: "app", environment: .development, connectionId: connectionId) + + let entry = FavoriteDatabaseEntry( + connectionId: connectionId, + database: "app", + environment: .development + ) + #expect(metadata.dirtyIds(for: .favoriteDatabase) == [FavoriteDatabasesStorage.syncId(for: entry)]) + } + + /// The record is keyed on identity alone. Hashing the environment into it would orphan the old + /// record on every re-tag and push a second one beside it. + @Test("Re-tagging a database keeps its sync id") + func syncIdIgnoresEnvironment() { + let connectionId = UUID() + let development = FavoriteDatabaseEntry( + connectionId: connectionId, + database: "app", + environment: .development + ) + let production = FavoriteDatabaseEntry( + connectionId: connectionId, + database: "app", + environment: .production ) #expect( - storage.favorites(for: connectionId).first?.environment - == FavoriteDatabaseEnvironment.none + FavoriteDatabasesStorage.syncId(for: development) + == FavoriteDatabasesStorage.syncId(for: production) ) } - @Test("Malformed storage is contained to the affected connection") - func malformedStorageReturnsEmpty() throws { - let (storage, defaults) = try makeStorage() + @Test("Two connections with the same database name get different sync ids") + func syncIdIncludesConnection() { + let first = FavoriteDatabaseEntry(connectionId: UUID(), database: "app", environment: .development) + let second = FavoriteDatabaseEntry(connectionId: UUID(), database: "app", environment: .development) + + #expect(FavoriteDatabasesStorage.syncId(for: first) != FavoriteDatabasesStorage.syncId(for: second)) + } + + @Test("Removing a favorite creates a sync tombstone") + func removeCreatesTombstone() throws { + let (storage, _, metadata) = try makeStorage() + let connectionId = UUID() + storage.setFavorite(database: "app", environment: .development, connectionId: connectionId) + storage.removeFavorite(database: "app", connectionId: connectionId) + + let entry = FavoriteDatabaseEntry( + connectionId: connectionId, + database: "app", + environment: .development + ) + let id = FavoriteDatabasesStorage.syncId(for: entry) + #expect(metadata.dirtyIds(for: .favoriteDatabase).isEmpty) + #expect(metadata.tombstones(for: .favoriteDatabase).contains { $0.id == id }) + } + + @Test("Remote apply helpers track no local change") + func withoutSyncTracksNothing() throws { + let (storage, _, metadata) = try makeStorage() let connectionId = UUID() - defaults.set( - Data("not-json".utf8), - forKey: "com.TablePro.favoriteDatabases.\(connectionId.uuidString)" + let entry = FavoriteDatabaseEntry( + connectionId: connectionId, + database: "orders", + environment: .testing ) + storage.setFavoriteWithoutSync(entry) + #expect(storage.favorites(for: connectionId).first?.environment == .testing) + + storage.removeFavoriteWithoutSync(id: FavoriteDatabasesStorage.syncId(for: entry)) #expect(storage.favorites(for: connectionId).isEmpty) + #expect(metadata.dirtyIds(for: .favoriteDatabase).isEmpty) + #expect(metadata.tombstones(for: .favoriteDatabase).isEmpty) } - @Test("Stored entries cannot cross connection boundaries") - func ignoresEntriesFromAnotherConnection() throws { - let (storage, defaults) = try makeStorage() - let requestedConnectionId = UUID() - let foreignConnectionId = UUID() - let json = """ - [{"connectionId":"\(foreignConnectionId.uuidString)","database":"private","environment":"production"}] - """ - defaults.set( - Data(json.utf8), - forKey: "com.TablePro.favoriteDatabases.\(requestedConnectionId.uuidString)" + /// A remote apply carries a payload, so it has to overwrite rather than insert-if-absent. + @Test("A remote apply overwrites the local environment") + func remoteApplyOverwritesEnvironment() throws { + let (storage, _, _) = try makeStorage() + let connectionId = UUID() + storage.setFavorite(database: "app", environment: .development, connectionId: connectionId) + + storage.setFavoriteWithoutSync( + FavoriteDatabaseEntry(connectionId: connectionId, database: "app", environment: .production) ) - #expect(storage.favorites(for: requestedConnectionId).isEmpty) + #expect(storage.favorites(for: connectionId).map(\.environment) == [.production]) } - @Test("Deleting a connection removes its persisted favorites") - func removeConnectionFavorites() throws { - let (storage, defaults) = try makeStorage() + @Test("Deleting a connection remotely leaves no tombstone to push back") + func remoteConnectionDeleteTracksNothing() throws { + let (storage, _, metadata) = try makeStorage() let connectionId = UUID() - storage.setFavorite(database: "app", environment: .production, connectionId: connectionId) + storage.setFavoriteWithoutSync( + FavoriteDatabaseEntry(connectionId: connectionId, database: "app", environment: .production) + ) - storage.removeFavorites(for: connectionId) + storage.removeFavoritesWithoutSync(for: connectionId) #expect(storage.favorites(for: connectionId).isEmpty) - #expect(defaults.object(forKey: "com.TablePro.favoriteDatabases.\(connectionId.uuidString)") == nil) + #expect(metadata.tombstones(for: .favoriteDatabase).isEmpty) } } diff --git a/TableProTests/Core/Sync/FavoriteDatabaseSyncTests.swift b/TableProTests/Core/Sync/FavoriteDatabaseSyncTests.swift new file mode 100644 index 000000000..3abd850c2 --- /dev/null +++ b/TableProTests/Core/Sync/FavoriteDatabaseSyncTests.swift @@ -0,0 +1,128 @@ +// +// FavoriteDatabaseSyncTests.swift +// TableProTests +// + +import CloudKit +import Foundation +import Testing +import TableProSyncTransport + +@testable import TablePro + +@Suite("Favorite database sync") +struct FavoriteDatabaseSyncTests { + private static let zoneID = CKRecordZone.ID( + zoneName: "TableProSync", + ownerName: CKCurrentUserDefaultName + ) + + /// `FavoriteDatabase` is declared but not deployed to the CloudKit Production schema, so every + /// field is unverified and the gated subscript drops every write. That is what keeps the type + /// inert: nothing reaches CloudKit until the schema ships and both sets flip together. + @Test("The record type is declared but withheld until the schema is deployed") + func recordTypeIsWithheld() { + #expect(SyncRecordType.allCases.contains(.favoriteDatabase)) + #expect(!SyncRecordType.favoriteDatabase.isWritable) + #expect(FavoriteDatabaseSyncField.writableKeys.isEmpty) + #expect(!FavoriteDatabaseSyncField.declaredKeys.isEmpty) + } + + @Test("A record of the withheld type is never published") + func recordsAreNotPublished() { + let record = CKRecord( + recordType: SyncRecordType.favoriteDatabase.rawValue, + recordID: CKRecord.ID( + recordName: SyncRecordType.favoriteDatabase.recordName(for: "abc"), + zoneID: Self.zoneID + ) + ) + + #expect(SyncSchemaGate.publishable(records: [record]).isEmpty) + #expect(SyncSchemaGate.withheldRecordTypes(in: [record]) == ["FavoriteDatabase"]) + #expect(SyncSchemaGate.publishable(deletions: [record.recordID]).isEmpty) + } + + /// `FavoriteDatabase_` has to beat `Favorite_` when a record name is parsed back, which the + /// longest-prefix ordering guarantees. + @Test("A record name round trips to the right type") + func recordNameRoundTrips() throws { + let name = SyncRecordType.favoriteDatabase.recordName(for: "abc123") + let parsed = try #require(SyncRecordType.parse(recordName: name)) + + #expect(parsed.type == .favoriteDatabase) + #expect(parsed.id == "abc123") + } + + @Test("The type is in scope for sync rather than device-local") + func syncScope() { + #expect(SyncRecordType.favoriteDatabase.syncScope == .synced) + } + + @Test("Every declared field carries a key") + func declaredKeys() { + #expect( + FavoriteDatabaseSyncField.declaredKeys + == ["connectionId", "database", "environment", "modifiedAtLocal", "schemaVersion"] + ) + } + + /// The mapper's decode has to survive an environment a future build introduces, or one record + /// from a newer device would drop the favorite instead of keeping it untagged. + @Test("An unknown remote environment decodes as Unassigned") + func unknownEnvironmentDecodes() throws { + let record = CKRecord( + recordType: SyncRecordType.favoriteDatabase.rawValue, + recordID: CKRecord.ID( + recordName: SyncRecordType.favoriteDatabase.recordName(for: "abc"), + zoneID: Self.zoneID + ) + ) + let connectionId = UUID() + record["connectionId"] = connectionId.uuidString + record["database"] = "app" + record["environment"] = "staging" + + let entry = try SyncRecordMapper.favoriteDatabase(from: record) + + #expect(entry.connectionId == connectionId) + #expect(entry.database == "app") + #expect(entry.environment == .unassigned) + } + + @Test("A record with no database is refused rather than decoded to an empty favorite") + func missingDatabaseThrows() { + let record = CKRecord( + recordType: SyncRecordType.favoriteDatabase.rawValue, + recordID: CKRecord.ID( + recordName: SyncRecordType.favoriteDatabase.recordName(for: "abc"), + zoneID: Self.zoneID + ) + ) + record["connectionId"] = UUID().uuidString + + #expect(throws: (any Error).self) { + try SyncRecordMapper.favoriteDatabase(from: record) + } + } + + /// Encoding writes through the gated subscript, so an unverified field lands nowhere. The record + /// is still well formed and correctly named; only its payload waits for the deploy. + @Test("Encoding produces the right record identity and writes no undeployed field") + func encodeIsInert() { + let entry = FavoriteDatabaseEntry( + connectionId: UUID(), + database: "app", + environment: .production + ) + let record = SyncRecordMapper.toCKRecord(favoriteDatabase: entry, in: Self.zoneID) + + #expect(record.recordType == "FavoriteDatabase") + #expect( + record.recordID.recordName + == SyncRecordType.favoriteDatabase.recordName(for: FavoriteDatabasesStorage.syncId(for: entry)) + ) + #expect(record["database"] == nil) + #expect(record["environment"] == nil) + } +} diff --git a/TableProTests/Models/FavoriteDatabaseGroupingTests.swift b/TableProTests/Models/FavoriteDatabaseGroupingTests.swift index 99a9a58be..cf3a9b97a 100644 --- a/TableProTests/Models/FavoriteDatabaseGroupingTests.swift +++ b/TableProTests/Models/FavoriteDatabaseGroupingTests.swift @@ -27,14 +27,14 @@ struct FavoriteDatabaseGroupingTests { entry("prod", .production), entry("dev10", .development), entry("dev2", .development), - entry("misc", .none), + entry("misc", .unassigned), entry("test", .testing) ], searchText: "", filter: .all ) - #expect(groups.map(\.environment) == [.development, .testing, .production, .none]) + #expect(groups.map(\.environment) == [.development, .testing, .production, .unassigned]) #expect(groups.first?.entries.map(\.database) == ["dev2", "dev10"]) } @@ -42,7 +42,7 @@ struct FavoriteDatabaseGroupingTests { func filtersByEnvironment() { let entries: Set = [ entry("dev", .development), - entry("misc", .none), + entry("misc", .unassigned), entry("prod", .production) ] let groups = FavoriteDatabaseGrouping.groups( @@ -62,8 +62,8 @@ struct FavoriteDatabaseGroupingTests { #expect(unassigned.first?.entries.map(\.database) == ["misc"]) } - @Test("Search matches a database or its environment title") - func searchMatchesNamesAndEnvironment() { + @Test("Search matches a database name") + func searchMatchesNames() { let entries: Set = [ entry("billing", .development), entry("warehouse", .production) @@ -74,14 +74,27 @@ struct FavoriteDatabaseGroupingTests { searchText: "bill", filter: .all ) - let environmentMatch = FavoriteDatabaseGrouping.groups( + + #expect(nameMatch.flatMap(\.entries).map(\.database) == ["billing"]) + } + + /// The search field is shared with saved queries and favorite tables, so matching the localized + /// environment title meant typing "product" to find a `products` table also listed every + /// Production database. The environment is already selectable in the filter above the list. + @Test("Search does not match the environment title") + func searchIgnoresEnvironmentTitle() { + let entries: Set = [ + entry("billing", .development), + entry("warehouse", .production) + ] + + let byTitle = FavoriteDatabaseGrouping.groups( entries: entries, - searchText: "Production", + searchText: FavoriteDatabaseEnvironment.production.title, filter: .all ) - #expect(nameMatch.flatMap(\.entries).map(\.database) == ["billing"]) - #expect(environmentMatch.flatMap(\.entries).map(\.database) == ["warehouse"]) + #expect(byTitle.isEmpty) } @Test("No matches produce no empty groups") diff --git a/TableProTests/Models/SharedSidebarStateTests.swift b/TableProTests/Models/SharedSidebarStateTests.swift index 8f75b4343..55ab1a3da 100644 --- a/TableProTests/Models/SharedSidebarStateTests.swift +++ b/TableProTests/Models/SharedSidebarStateTests.swift @@ -103,8 +103,8 @@ struct SharedSidebarStateTests { let restored = SharedSidebarState.forConnection(id) #expect(restored.favoriteDatabaseEnvironmentFilter == .testing) - restored.favoriteDatabaseEnvironmentFilter = .all SharedSidebarState.removeConnection(id) + SidebarPersistenceKey.removeAll(connectionId: id) } @Test("filter text is independent across different connections") diff --git a/TableProTests/ViewModels/FavoritesExpansionStateTests.swift b/TableProTests/ViewModels/FavoritesExpansionStateTests.swift new file mode 100644 index 000000000..0bfab919b --- /dev/null +++ b/TableProTests/ViewModels/FavoritesExpansionStateTests.swift @@ -0,0 +1,93 @@ +// +// FavoritesExpansionStateTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@MainActor +@Suite("FavoritesExpansionState") +struct FavoritesExpansionStateTests { + private func makeState() throws -> (FavoritesExpansionState, UserDefaults, String) { + let suite = "FavoritesExpansionStateTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + return (FavoritesExpansionState(defaults: defaults), defaults, suite) + } + + @Test("A group is expanded until it is collapsed") + func collapsingAGroup() throws { + let (state, _, _) = try makeState() + let connectionId = UUID() + + #expect(state.isDatabaseEnvironmentExpanded(.production, for: connectionId)) + + state.setDatabaseEnvironmentExpanded(.production, expanded: false, for: connectionId) + #expect(!state.isDatabaseEnvironmentExpanded(.production, for: connectionId)) + #expect(state.isDatabaseEnvironmentExpanded(.testing, for: connectionId)) + } + + @Test("Collapsed groups survive a reload") + func collapsedGroupsPersist() throws { + let (state, defaults, _) = try makeState() + let connectionId = UUID() + state.setDatabaseEnvironmentExpanded(.development, expanded: false, for: connectionId) + + let reloaded = FavoritesExpansionState(defaults: defaults) + #expect(!reloaded.isDatabaseEnvironmentExpanded(.development, for: connectionId)) + } + + /// The bug: `Set` fails the whole payload on one unknown case, so a + /// build that added an environment and was then rolled back discarded the collapsed state of + /// every group of every connection, permanently, from the next write onward. + @Test("One unrecognized environment does not discard the other connections") + func unknownEnvironmentIsSkipped() throws { + let known = UUID() + let partial = UUID() + let stored: [UUID: Set] = [ + known: ["production"], + partial: ["staging", "testing"] + ] + + let decoded = FavoritesExpansionState.decodeCollapsedEnvironments( + try JSONEncoder().encode(stored) + ) + + #expect(decoded[known] == [.production]) + #expect(decoded[partial] == [.testing]) + } + + @Test("A connection whose every collapsed environment is unknown drops out entirely") + func fullyUnknownConnectionDropsOut() throws { + let stored: [UUID: Set] = [UUID(): ["staging"]] + + #expect( + FavoritesExpansionState + .decodeCollapsedEnvironments(try JSONEncoder().encode(stored)) + .isEmpty + ) + } + + @Test("Malformed data reads as no collapsed groups") + func malformedDataIsEmpty() { + #expect(FavoritesExpansionState.decodeCollapsedEnvironments(Data("not-json".utf8)).isEmpty) + #expect(FavoritesExpansionState.decodeCollapsedEnvironments(nil).isEmpty) + } + + @Test("Deleting a connection forgets its groups and leaves the others alone") + func removeConnection() throws { + let (state, _, _) = try makeState() + let deleted = UUID() + let kept = UUID() + state.setDatabaseEnvironmentExpanded(.production, expanded: false, for: deleted) + state.setDatabaseEnvironmentExpanded(.production, expanded: false, for: kept) + + state.removeConnection(deleted) + + #expect(state.isDatabaseEnvironmentExpanded(.production, for: deleted)) + #expect(!state.isDatabaseEnvironmentExpanded(.production, for: kept)) + } +} diff --git a/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift b/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift index fde88ec77..7de433a51 100644 --- a/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift +++ b/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift @@ -49,6 +49,7 @@ struct QuickSwitcherViewModelTests { schemaProviderRegistry: SchemaProviderRegistry(), sqlFavoriteManager: sqlFavoriteManager ?? live.sqlFavoriteManager, favoriteTablesStorage: live.favoriteTablesStorage, + favoriteDatabasesStorage: live.favoriteDatabasesStorage, aiChatStorage: live.aiChatStorage, aiKeyStorage: live.aiKeyStorage, groupStorage: live.groupStorage, diff --git a/TableProTests/ViewModels/WelcomeViewModelTests.swift b/TableProTests/ViewModels/WelcomeViewModelTests.swift index 118df2f33..d37ef640e 100644 --- a/TableProTests/ViewModels/WelcomeViewModelTests.swift +++ b/TableProTests/ViewModels/WelcomeViewModelTests.swift @@ -84,6 +84,7 @@ final class WelcomeViewModelTests: XCTestCase { schemaProviderRegistry: live.schemaProviderRegistry, sqlFavoriteManager: live.sqlFavoriteManager, favoriteTablesStorage: live.favoriteTablesStorage, + favoriteDatabasesStorage: live.favoriteDatabasesStorage, aiChatStorage: live.aiChatStorage, aiKeyStorage: live.aiKeyStorage, groupStorage: groupStorage, diff --git a/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift b/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift index e18952919..6f1dcceec 100644 --- a/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift +++ b/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift @@ -25,7 +25,7 @@ struct DatabaseTreeMenuSpecTests { selectedContainers: [DatabaseContainerRef] = [], isReadOnly: Bool = false, isFavorite: Bool = false, - favoriteDatabaseEnvironment: FavoriteDatabaseEnvironment? = nil, + favoriteDatabaseEnvironments: [String: FavoriteDatabaseEnvironment] = [:], activeDatabase: String? = "app", activeSchema: String? = "public", canReachOtherDatabases: Bool = true, @@ -57,7 +57,7 @@ struct DatabaseTreeMenuSpecTests { schemaEntityNamePlural: "Schemas", objectKindTitles: [.table: "Tables"], isFavorite: isFavorite, - favoriteDatabaseEnvironment: favoriteDatabaseEnvironment, + favoriteDatabaseEnvironments: favoriteDatabaseEnvironments, showObjectIcons: true, showObjectComments: false, rowSize: .matchSystem, @@ -299,9 +299,9 @@ struct DatabaseTreeMenuSpecTests { #expect(titles(items).contains(String(localized: "Add to Favorites"))) for environment in FavoriteDatabaseEnvironment.allCases { - #expect(issued.contains(.setFavoriteDatabase(database: "analytics", environment: environment))) + #expect(issued.contains(.setFavoriteDatabases(databases: ["analytics"], environment: environment))) } - #expect(!issued.contains(.removeFavoriteDatabase("analytics"))) + #expect(!issued.contains(.removeFavoriteDatabases(["analytics"]))) } @Test("A favorite database can change environment or be removed") @@ -309,13 +309,64 @@ struct DatabaseTreeMenuSpecTests { let database = DatabaseMetadata.minimal(name: "analytics", isSystem: false) let items = DatabaseTreeMenuSpec.items(for: context( clicked: .database(database), - favoriteDatabaseEnvironment: .production + favoriteDatabaseEnvironments: ["analytics": .production] )) let issued = commands(items) #expect(titles(items).contains(String(localized: "Environment"))) - #expect(issued.contains(.removeFavoriteDatabase("analytics"))) - #expect(issued.contains(.setFavoriteDatabase(database: "analytics", environment: .development))) + #expect(issued.contains(.removeFavoriteDatabases(["analytics"]))) + #expect(issued.contains(.setFavoriteDatabases(databases: ["analytics"], environment: .development))) + } + + /// A right-click inside a multi-selection acts on the whole selection, which is what + /// `NSTableView.clickedRow` documents and what `FieldDrivenList` already does. The favorite + /// items used to disappear entirely once a second database was selected. + @Test("A multi-database selection still offers the favorite items, for every database") + func favoriteItemsSurviveMultiSelection() { + let clicked = DatabaseMetadata.minimal(name: "analytics", isSystem: false) + let items = DatabaseTreeMenuSpec.items(for: context( + clicked: .database(clicked), + selectedContainers: [ + .database("analytics", isSystem: false), + .database("reporting", isSystem: false) + ] + )) + let issued = commands(items) + + #expect(titles(items).contains(String(localized: "Add to Favorites"))) + #expect(issued.contains( + .setFavoriteDatabases(databases: ["analytics", "reporting"], environment: .production) + )) + } + + /// Retagging is only what the menu offers when every target is already a favorite; a selection + /// that mixes the two still says "Add to Favorites", and no environment is checked. + @Test("A mixed selection offers Add to Favorites with no environment checked") + func mixedSelectionOffersAdd() { + let clicked = DatabaseMetadata.minimal(name: "analytics", isSystem: false) + let items = DatabaseTreeMenuSpec.items(for: context( + clicked: .database(clicked), + selectedContainers: [ + .database("analytics", isSystem: false), + .database("reporting", isSystem: false) + ], + favoriteDatabaseEnvironments: ["analytics": .production] + )) + + #expect(titles(items).contains(String(localized: "Add to Favorites"))) + #expect(commands(items).contains(.removeFavoriteDatabases(["analytics", "reporting"]))) + } + + /// An engine with no database dimension names no database on its container refs, and a favorite + /// that names nothing is unreachable. + @Test("A schema row offers no favorite items") + func schemaRowOffersNoFavoriteItems() { + let items = DatabaseTreeMenuSpec.items( + for: context(clicked: .schema(database: "app", schema: "public")) + ) + + #expect(!titles(items).contains(String(localized: "Add to Favorites"))) + #expect(!titles(items).contains(String(localized: "Environment"))) } // MARK: - Shape diff --git a/TableProTests/Views/Sidebar/FavoriteDatabaseMenuTests.swift b/TableProTests/Views/Sidebar/FavoriteDatabaseMenuTests.swift new file mode 100644 index 000000000..069f7a200 --- /dev/null +++ b/TableProTests/Views/Sidebar/FavoriteDatabaseMenuTests.swift @@ -0,0 +1,91 @@ +// +// FavoriteDatabaseMenuTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("FavoriteDatabaseMenu") +struct FavoriteDatabaseMenuTests { + @Test("One database that is not a favorite offers Add to Favorites with nothing checked") + func singleNonFavorite() { + let state = FavoriteDatabaseSelectionState(environments: [nil]) + + #expect(FavoriteDatabaseMenu.submenuTitle(for: state) == String(localized: "Add to Favorites")) + #expect(!state.hasFavorite) + #expect(FavoriteDatabaseMenu.environmentItems(for: state).allSatisfy { !$0.isOn }) + } + + @Test("One favorite offers Environment with its own tag checked") + func singleFavorite() { + let state = FavoriteDatabaseSelectionState(environments: [.production]) + + #expect(FavoriteDatabaseMenu.submenuTitle(for: state) == String(localized: "Environment")) + #expect(state.hasFavorite) + let checked = FavoriteDatabaseMenu.environmentItems(for: state).filter(\.isOn) + #expect(checked.map(\.environment) == [.production]) + } + + @Test("A selection that agrees on its tag keeps the checkmark") + func uniformSelection() { + let state = FavoriteDatabaseSelectionState(environments: [.testing, .testing]) + + #expect(FavoriteDatabaseMenu.submenuTitle(for: state) == String(localized: "Environment")) + #expect(FavoriteDatabaseMenu.environmentItems(for: state).filter(\.isOn).map(\.environment) == [.testing]) + } + + /// Checking one option would claim every selected database carries it. + @Test("A selection that disagrees on its tag checks nothing") + func mixedEnvironments() { + let state = FavoriteDatabaseSelectionState(environments: [.testing, .production]) + + #expect(FavoriteDatabaseMenu.submenuTitle(for: state) == String(localized: "Environment")) + #expect(FavoriteDatabaseMenu.environmentItems(for: state).allSatisfy { !$0.isOn }) + } + + @Test("A selection where only some are favorites still offers Add to Favorites") + func partiallyFavorite() { + let state = FavoriteDatabaseSelectionState(environments: [.production, nil]) + + #expect(FavoriteDatabaseMenu.submenuTitle(for: state) == String(localized: "Add to Favorites")) + #expect(state.hasFavorite) + #expect(!state.isEntirelyFavorite) + #expect(FavoriteDatabaseMenu.environmentItems(for: state).allSatisfy { !$0.isOn }) + } + + @Test("An empty selection has nothing to offer") + func emptySelection() { + let state = FavoriteDatabaseSelectionState(environments: []) + + #expect(state.isEmpty) + #expect(!state.hasFavorite) + #expect(!state.isEntirelyFavorite) + } + + @Test("Every environment gets an item, in declaration order") + func coversEveryEnvironment() { + let items = FavoriteDatabaseMenu.environmentItems( + for: FavoriteDatabaseSelectionState(environments: [nil]) + ) + + #expect(items.map(\.environment) == FavoriteDatabaseEnvironment.allCases) + #expect(items.map(\.title) == FavoriteDatabaseEnvironment.allCases.map(\.title)) + } + + /// One bucket, one name. The group header, the filter popup and both menus used to disagree, + /// showing "No Environment" beside a group labelled "Unassigned". + @Test("The unassigned bucket has exactly one name everywhere") + func unassignedHasOneName() { + let menuTitle = FavoriteDatabaseMenu.environmentItems( + for: FavoriteDatabaseSelectionState(environments: [nil]) + ) + .first { $0.environment == .unassigned }? + .title + + #expect(menuTitle == FavoriteDatabaseEnvironment.unassigned.title) + #expect(menuTitle == FavoriteDatabaseEnvironmentFilter.unassigned.title) + } +} diff --git a/TableProTests/Views/Sidebar/FavoritesEmptyStateTests.swift b/TableProTests/Views/Sidebar/FavoritesEmptyStateTests.swift new file mode 100644 index 000000000..c50a47d16 --- /dev/null +++ b/TableProTests/Views/Sidebar/FavoritesEmptyStateTests.swift @@ -0,0 +1,81 @@ +// +// FavoritesEmptyStateTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("FavoritesEmptyState") +struct FavoritesEmptyStateTests { + private func input( + isInitialLoadComplete: Bool = true, + hasAnyFavorite: Bool = true, + hasVisibleContent: Bool = false, + searchText: String = "", + isEnvironmentFiltered: Bool = false + ) -> FavoritesEmptyState.Input { + FavoritesEmptyState.Input( + isInitialLoadComplete: isInitialLoadComplete, + hasAnyFavorite: hasAnyFavorite, + hasVisibleContent: hasVisibleContent, + searchText: searchText, + isEnvironmentFiltered: isEnvironmentFiltered + ) + } + + @Test("Anything visible wins over every empty state") + func contentWins() { + #expect( + FavoritesEmptyState.resolve(input( + hasVisibleContent: true, + searchText: "nothing matches this", + isEnvironmentFiltered: true + )) == .content + ) + } + + @Test("Nothing loaded yet and nothing stored reads as loading") + func loading() { + #expect( + FavoritesEmptyState.resolve(input(isInitialLoadComplete: false, hasAnyFavorite: false)) + == .loading + ) + } + + @Test("A connection with no favorites at all gets the onboarding state") + func noFavorites() { + #expect(FavoritesEmptyState.resolve(input(hasAnyFavorite: false)) == .noFavorites) + } + + @Test("A failed search reports the term the user typed") + func searchMiss() { + #expect(FavoritesEmptyState.resolve(input(searchText: "orders")) == .noSearchMatch("orders")) + } + + /// The bug: an environment filter that matched nothing rendered `ContentUnavailableView.search` + /// for a search the user never ran, so a persisted filter greeted them with "No Results" and an + /// empty query on the next launch. + @Test("A filter miss with no search term is a filter state, not a search state") + func filterMissIsNotASearchMiss() { + #expect( + FavoritesEmptyState.resolve(input(searchText: "", isEnvironmentFiltered: true)) + == .noFilterMatch + ) + } + + @Test("A search term wins over the filter when both are narrowing") + func searchWinsOverFilter() { + #expect( + FavoritesEmptyState.resolve(input(searchText: "app", isEnvironmentFiltered: true)) + == .noSearchMatch("app") + ) + } + + @Test("Favorites exist, nothing is narrowing, and nothing shows: still the onboarding state") + func neitherNarrowing() { + #expect(FavoritesEmptyState.resolve(input()) == .noFavorites) + } +} diff --git a/TableProTests/Views/Sidebar/SidebarOutlineScaffoldTests.swift b/TableProTests/Views/Sidebar/SidebarOutlineScaffoldTests.swift index 69c504ab2..be47c4f29 100644 --- a/TableProTests/Views/Sidebar/SidebarOutlineScaffoldTests.swift +++ b/TableProTests/Views/Sidebar/SidebarOutlineScaffoldTests.swift @@ -732,7 +732,7 @@ struct DatabaseTreeFavoriteRefreshTests { ) settle(window) - #expect(coordinator.favoriteDatabaseEnvironment(for: "shop") == .production) + #expect(coordinator.favoriteDatabaseEnvironments()["shop"] == .production) #expect(cell.hostedView === host) let favoritePixels = try trailingPixels(of: cell) #expect(unfavoritePixels != favoritePixels) @@ -740,7 +740,7 @@ struct DatabaseTreeFavoriteRefreshTests { databaseStorage.removeFavorite(database: "shop", connectionId: connectionId) settle(window) - #expect(coordinator.favoriteDatabaseEnvironment(for: "shop") == nil) + #expect(coordinator.favoriteDatabaseEnvironments()["shop"] == nil) #expect(cell.hostedView === host) #expect(try trailingPixels(of: cell) != favoritePixels) } diff --git a/docs/features/favorites.mdx b/docs/features/favorites.mdx index c4892c14b..67f0cdb05 100644 --- a/docs/features/favorites.mdx +++ b/docs/features/favorites.mdx @@ -7,15 +7,21 @@ The Favorites tab keeps pinned databases and tables beside saved SQL. The sideba ## Database Favorites -In Tree layout, right-click a database and choose **Add to Favorites**, then assign it to Development, Testing, Production, or Unassigned. A filled star marks the database in the tree. +Mark a database as a favorite from any of three places, then assign it to Development, Testing, Production, or Unassigned: -Favorite databases appear first in the Favorites tab, grouped by environment. Use the Environment menu to show one group, and use the sidebar search field to filter by database or environment name. Double-click a database, or select it and press Return, to make it active. Right-click it to change its environment or remove it. +- **The database switcher**: click the database name in the toolbar, then right-click a row +- **Tree layout**: hover a database row and click the star, or right-click the row +- **The menu bar**: **Database > Favorite Database**, which works in every sidebar layout + +Right-clicking a database that sits inside a multiple selection acts on the whole selection. A filled star marks the database in the tree and in the switcher. + +Favorite databases appear first in the Favorites tab, grouped by environment. Use the Environment menu to show one group, and use the sidebar search field to filter by database name. Double-click a database, or select it and press Return, to make it active. Right-click it to change its environment or remove it. Favorite databases grouped into Development, Testing, and Production in the Favorites sidebar -Database favorites are stored locally for each connection. Database names that no longer exist stay in Favorites so you can remove them; trying to activate one shows the database driver's normal switch error. +Database favorites are scoped to the connection. Database names that no longer exist stay in Favorites so you can remove them; trying to activate one shows the database driver's normal switch error. ## Table Favorites diff --git a/docs/features/icloud-sync.mdx b/docs/features/icloud-sync.mdx index 110cca65a..b0fea3292 100644 --- a/docs/features/icloud-sync.mdx +++ b/docs/features/icloud-sync.mdx @@ -14,6 +14,7 @@ TablePro syncs your connections, groups, table favorites, saved queries, setting | **Groups & Tags** | Yes | Full connection organization, including nested group hierarchy and sort order | | **SSH Profiles** | Yes | Named [SSH profiles](/features/ssh-profiles). Has its own toggle. | | **Table Favorites** | Yes | Favorited table names shown in the Favorites tab and pinned in table lists | +| **Database Favorites** | Yes | Favorited databases and their Development / Testing / Production tags. Has its own toggle. | | **Saved Queries** | Yes | Saved SQL queries and their folders. Has its own toggle. | | **Settings** | Yes | General, Appearance, Editor, Data Grid, History, Tabs, Keyboard, and AI settings, plus custom AI slash commands and saved per-table column widths and order | | **Linked SQL Folders** | No | Folder paths are per-Mac. Link the same Git repo on each Mac after cloning. Cached file metadata (`linked_sql_index.db`) is also local. | @@ -34,7 +35,7 @@ Press `Cmd+,`, open **Settings > Account**, toggle iCloud Sync on, choose which iCloud Sync settings -Connections (with a nested Passwords opt-in), Groups & Tags, SSH Profiles, Settings, Table Favorites, and Saved Queries each have their own toggle. Custom AI slash commands and saved column layouts ride along with the Settings toggle. +Connections (with a nested Passwords opt-in), Groups & Tags, SSH Profiles, Settings, Table Favorites, Database Favorites, and Saved Queries each have their own toggle. Custom AI slash commands and saved column layouts ride along with the Settings toggle. ## Excluding individual connections From 556abde254fc2762de86a767e90a67a7f53bb705 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 20 Aug 2026 18:14:01 +0700 Subject: [PATCH 5/5] fix(sync): keep a local-only connection's database favorites out of iCloud --- TablePro/Core/Sync/SyncCoordinator.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/TablePro/Core/Sync/SyncCoordinator.swift b/TablePro/Core/Sync/SyncCoordinator.swift index a1b47b936..bf77d746b 100644 --- a/TablePro/Core/Sync/SyncCoordinator.swift +++ b/TablePro/Core/Sync/SyncCoordinator.swift @@ -1013,6 +1013,9 @@ final class SyncCoordinator { } } + /// A connection the user marked local only never reaches iCloud, and neither do the database + /// names hanging off it. Tombstones are not filtered: a deletion only ever removes something, + /// and a connection can be marked local only after its favorites were already pushed. private func collectDirtyDatabaseFavorites( into records: inout [CKRecord], deletions: inout [CKRecord.ID], @@ -1020,8 +1023,13 @@ final class SyncCoordinator { ) { let dirtyIds = changeTracker.dirtyRecords(for: .favoriteDatabase) if !dirtyIds.isEmpty { + let localOnlyIds = Set( + services.connectionStorage.loadConnections().filter(\.localOnly).map(\.id) + ) let favorites = services.favoriteDatabasesStorage.loadFavorites() - for entry in favorites where dirtyIds.contains(FavoriteDatabasesStorage.syncId(for: entry)) { + for entry in favorites + where dirtyIds.contains(FavoriteDatabasesStorage.syncId(for: entry)) + && !localOnlyIds.contains(entry.connectionId) { records.append(SyncRecordMapper.toCKRecord(favoriteDatabase: entry, in: zoneID)) } }