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..1afb16dbd 100644 --- a/docs/features/favorites.mdx +++ b/docs/features/favorites.mdx @@ -1,9 +1,21 @@ --- 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. + + + 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 diff --git a/docs/images/favorite-databases.png b/docs/images/favorite-databases.png new file mode 100644 index 000000000..f2b9737c1 Binary files /dev/null and b/docs/images/favorite-databases.png differ