diff --git a/CHANGELOG.md b/CHANGELOG.md index 066ecbd41..c060d4150 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 +- Database favorites can be tagged by environment, filtered in the Favorites sidebar, opened directly, and synced through iCloud. (#1553) - Open in Window on a row inspector text field, for reading or editing a long value on a bigger surface. ### Fixed 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 894d0eb66..4be552843 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 cadeb17a4..140f7c07e 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 f2878425f..172f311d5 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift @@ -64,6 +64,7 @@ struct MenuValidationContext: Equatable { var supportsUserManagement = false var supportsSchemaSwitching = false var canFilterDatabases = false + var canFavoriteActiveDatabase = false var hasDatabaseFilter = false } @@ -190,6 +191,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(_:)): @@ -269,6 +272,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 243156b21..87991d962 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -287,16 +287,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) - 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( @@ -331,18 +326,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) - } - 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 new file mode 100644 index 000000000..0b63f704b --- /dev/null +++ b/TablePro/Core/Storage/FavoriteDatabasesStorage.swift @@ -0,0 +1,186 @@ +// +// FavoriteDatabasesStorage.swift +// TablePro +// + +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, + syncTracker: SyncChangeTracker = .shared + ) { + self.defaults = defaults + self.syncTracker = syncTracker + } + + 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 { + cache = [] + return [] + } + let valid = decoded.filter { !$0.database.isEmpty } + cache = valid + return valid + } + + internal func favorites(for connectionId: UUID) -> Set { + loadFavorites().filter { $0.connectionId == connectionId } + } + + internal func setFavorite( + database: String, + environment: FavoriteDatabaseEnvironment, + connectionId: UUID + ) { + let entry = FavoriteDatabaseEntry( + connectionId: connectionId, + database: database, + environment: environment + ) + 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) { + 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) { + 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(_ favorites: Set) { + cache = favorites + guard !favorites.isEmpty else { + defaults.removeObject(forKey: Self.storageKey) + return + } + do { + defaults.set(try JSONEncoder().encode(favorites), forKey: Self.storageKey) + } catch { + Self.logger.error("Failed to encode favorite databases: \(error.localizedDescription, privacy: .public)") + } + } +} diff --git a/TablePro/Core/Storage/FavoriteTablesStorage.swift b/TablePro/Core/Storage/FavoriteTablesStorage.swift index ce84df362..f1d3cee3a 100644 --- a/TablePro/Core/Storage/FavoriteTablesStorage.swift +++ b/TablePro/Core/Storage/FavoriteTablesStorage.swift @@ -107,6 +107,18 @@ final class FavoriteTablesStorage: @unchecked Sendable { @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: @unchecked Sendable { 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 ec8a807cf..717755932 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,7 +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) + ConnectionLocalState.purge(connectionIds: connectionIdsToDelete, origin: .remote) let favoriteManager = services.sqlFavoriteManager Task { for id in connectionIdsToDelete { @@ -716,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() { @@ -983,4 +1013,32 @@ 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], + zoneID: CKRecordZone.ID + ) { + 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)) + && !localOnlyIds.contains(entry.connectionId) { + 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 new file mode 100644 index 000000000..0fe365c67 --- /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:)) ?? .unassigned + } +} diff --git a/TablePro/Models/Favorites/FavoriteDatabaseEnvironment.swift b/TablePro/Models/Favorites/FavoriteDatabaseEnvironment.swift new file mode 100644 index 000000000..454924d15 --- /dev/null +++ b/TablePro/Models/Favorites/FavoriteDatabaseEnvironment.swift @@ -0,0 +1,33 @@ +// +// FavoriteDatabaseEnvironment.swift +// TablePro +// + +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 unassigned + + internal var title: String { + switch self { + case .development: String(localized: "Development") + case .testing: String(localized: "Testing") + case .production: String(localized: "Production") + case .unassigned: String(localized: "Unassigned") + } + } + + internal var iconName: String { + switch self { + case .development: "wrench.and.screwdriver" + case .testing: "checkmark.circle" + case .production: "lock.shield" + case .unassigned: "tray" + } + } +} diff --git a/TablePro/Models/Favorites/FavoriteDatabaseEnvironmentFilter.swift b/TablePro/Models/Favorites/FavoriteDatabaseEnvironmentFilter.swift new file mode 100644 index 000000000..3fcef4dbf --- /dev/null +++ b/TablePro/Models/Favorites/FavoriteDatabaseEnvironmentFilter.swift @@ -0,0 +1,29 @@ +// +// 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 { + guard let environment else { return String(localized: "All Environments") } + return environment.title + } + + internal var environment: FavoriteDatabaseEnvironment? { + switch self { + case .all: nil + case .development: .development + case .testing: .testing + case .production: .production + case .unassigned: .unassigned + } + } +} 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..bc2e030ab --- /dev/null +++ b/TablePro/Models/Favorites/FavoriteDatabaseGrouping.swift @@ -0,0 +1,32 @@ +// +// 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) + } + + 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/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/Models/UI/SharedSidebarState.swift b/TablePro/Models/UI/SharedSidebarState.swift index 93e694d47..f5099313a 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/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 88fa54282..da439984e 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -12797,6 +12797,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" : { @@ -38667,6 +38701,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" : { @@ -44922,6 +44990,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" : { @@ -50428,6 +50530,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" : { @@ -73181,6 +73351,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" : { @@ -74920,6 +75124,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" : { "localizations" : { "ko" : { @@ -86988,6 +87226,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" : { @@ -115243,6 +115515,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" : { @@ -124453,6 +124759,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 783dee60f..99aecb4f4 100644 --- a/TablePro/ViewModels/FavoritesExpansionState.swift +++ b/TablePro/ViewModels/FavoritesExpansionState.swift @@ -13,11 +13,17 @@ 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() { + @ObservationIgnored private let defaults: UserDefaults + + internal init(defaults: UserDefaults = AppStorageEnvironment.shared.defaults) { + self.defaults = defaults load() } @@ -29,6 +35,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,26 +68,77 @@ 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), + 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 } + 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() { + 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 a019c835e..33e4e77d4 100644 --- a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift +++ b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift @@ -67,6 +67,7 @@ struct DatabaseSwitcherPopover: View { let dismiss: () -> Void @State private var viewModel: DatabaseSwitcherViewModel @State private var supportsCreateDatabase = false + @State private var favoriteDatabases: Set = [] /// One declaration, read by this view's own frame and by whoever presents it, so the /// surface and its host can never disagree about how big it is. @@ -144,6 +145,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 { @@ -225,15 +230,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 @@ -267,6 +287,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 5da9fcc8f..1b55dc8b5 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 5d1e09e63..743e24411 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift @@ -76,6 +76,18 @@ extension DatabaseTreeOutlineCoordinator { sidebarState?.clearRecentTables(inDatabase: mainCoordinator?.browseDatabaseName) case .useAsActive(let container): useAsActive(container) + 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 1eff439df..a2fafa611 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift @@ -59,6 +59,7 @@ extension DatabaseTreeOutlineCoordinator: NSMenuDelegate { schemaEntityNamePlural: PluginManager.shared.schemaEntityNamePlural(for: databaseType), objectKindTitles: objectKindTitles(), isFavorite: clickedRef.map { isFavorite($0) } ?? false, + 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 3fb4de20c..8c8bbc33a 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift @@ -15,6 +15,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 @@ -55,10 +56,15 @@ final class DatabaseTreeOutlineCoordinator: NSObject { internal let schemaService = SchemaService.shared private var favoriteTables: Set = [] - private let favoritesObserver = OSAllocatedUnfairLock<(any NSObjectProtocol)?>(uncheckedState: nil) + private var favoriteDatabases: Set = [] + private let favoritesObservers = OSAllocatedUnfairLock<[any NSObjectProtocol]>(uncheckedState: []) - init(favoriteTablesStorage: FavoriteTablesStorage = .shared) { + init( + favoriteTablesStorage: FavoriteTablesStorage = .shared, + favoriteDatabasesStorage: FavoriteDatabasesStorage = .shared + ) { self.favoriteTablesStorage = favoriteTablesStorage + self.favoriteDatabasesStorage = favoriteDatabasesStorage super.init() } @@ -74,7 +80,7 @@ final class DatabaseTreeOutlineCoordinator: NSObject { func attach(outlineView: NSOutlineView) { self.outlineView = outlineView - let observer = NotificationCenter.default.addObserver( + let tableObserver = NotificationCenter.default.addObserver( forName: .favoriteTablesDidChange, object: nil, queue: .main ) { [weak self] _ in MainActor.assumeIsolated { @@ -83,13 +89,22 @@ final class DatabaseTreeOutlineCoordinator: NSObject { self.refreshVisibleRows() } } - favoritesObserver.withLockUnchecked { $0 = observer } + favoritesObservers.withLockUnchecked { $0.append(tableObserver) } + + let databaseObserver = NotificationCenter.default.addObserver( + forName: .favoriteDatabasesDidChange, object: nil, queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + guard let self else { return } + self.reloadFavorites() + self.refreshVisibleRows() + } + } + favoritesObservers.withLockUnchecked { $0.append(databaseObserver) } } deinit { - if let observer = favoritesObserver.withLockUnchecked({ $0 }) { - NotificationCenter.default.removeObserver(observer) - } + favoritesObservers.withLockUnchecked { $0.forEach(NotificationCenter.default.removeObserver) } } func update(from view: DatabaseTreeOutlineView) { @@ -241,6 +256,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 { @@ -257,8 +273,29 @@ 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 favoriteDatabases.contains { $0.database == metadata.name } + default: + guard let ref = DatabaseTreeSelection.tableRef(of: node) else { return false } + return isFavorite(ref) + } + } + + 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) { @@ -469,7 +506,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 19ab10793..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 @@ -58,12 +70,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 +119,24 @@ struct DatabaseTreeRowView: View { .lineLimit(1) } + private func databaseRow(_ metadata: DatabaseMetadata) -> some View { + let toggle = { actions.toggleFavoriteDatabase(metadata.name) } + return HStack(spacing: 6) { + header( + text: metadata.name, + systemImage: metadata.isSystemDatabase ? "gearshape" : "cylinder", + isActive: metadata.name == context.activeDatabase, + isSystem: metadata.isSystemDatabase + ) + Spacer(minLength: 4) + FavoriteStarButton(isFavorite: isFavorite, isRowHovered: isHovered, toggle: toggle) + } + .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 { 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/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/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..82ef1aa89 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.isNarrowingDatabases ? "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.isNarrowingDatabases || 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.isNarrowingDatabases 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..dafc557b6 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 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 919ef1795..f4b400b56 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,55 @@ 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 { + 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 && searchText.isEmpty { + case .noFavorites: emptyState - } else if items.isEmpty && filteredTables.isEmpty && teamLibraryQueries.isEmpty { - noMatchState - } else { - favoritesList(items, filteredTables: filteredTables) + case .noFilterMatch: + noFilterMatchState + case .noSearchMatch(let term): + noSearchMatchState(term) + case .content: + 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 +269,18 @@ 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, + isNarrowingDatabases: !searchText.isEmpty + || sharedSidebarState.favoriteDatabaseEnvironmentFilter != .all, tables: filteredTables, queryNodes: items, teamQueries: teamLibraryQueries.map { @@ -278,6 +333,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 +346,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: "%@: %@"), + databaseEntityName, + entry.database + )) + } + @ViewBuilder private func favoriteQueryRow(_ node: FavoriteNode) -> some View { switch node.content { @@ -362,6 +447,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, forceNonPreview: true, activateGridFocus: true) case .query(let node): @@ -382,8 +471,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 @@ -407,6 +501,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, forceNonPreview: true, activateGridFocus: true) case .showERDiagram: @@ -467,6 +574,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 @@ -496,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 4be23e276..35318a90c 100644 --- a/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift +++ b/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift @@ -31,6 +31,9 @@ internal struct DatabaseTreeMenuContext { internal let schemaEntityNamePlural: String internal let objectKindTitles: [SidebarObjectKind: String] internal let isFavorite: Bool + /// 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 @@ -219,11 +222,26 @@ internal enum DatabaseTreeMenuSpec { items.append(.command(String(localized: "Refresh"), .refreshContainers(targets))) items.append(.command(copyNamesTitle(count: targets.count), .copyContainerNames(targets))) + 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( containers: targets, activeDatabase: context.activeDatabase, canReachOtherDatabases: context.canReachOtherDatabases ) { + items.append(.separator) items.append(.command(String(localized: "Export…"), .exportContainers(targets))) } guard !droppable.isEmpty else { return items } @@ -232,6 +250,28 @@ internal enum DatabaseTreeMenuSpec { return items } + private static func favoriteDatabaseItems( + databases: [String], + state: FavoriteDatabaseSelectionState + ) -> [DatabaseTreeMenuItem] { + 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 { switch container.kind { case .database: 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/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..8c1a0d2cb 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,36 @@ 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) + )) + } + let state = FavoriteDatabaseSelectionState(environments: [entry.environment]) + items.append(.submenu( + title: FavoriteDatabaseMenu.submenuTitle(for: state), + items: FavoriteDatabaseMenu.environmentItems(for: state).map { item in + .command(SidebarMenuEntry( + title: item.title, + command: .setDatabaseEnvironment(entry, item.environment), + isOn: item.isOn + )) + } + )) + items.append(.separator) + items.append(.destructive(FavoriteDatabaseMenu.removeTitle, .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..9b182cc1b 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 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 feec084d6..8233ac1a2 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 { @@ -33,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/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 348198fb6..1305779fa 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -105,6 +105,7 @@ struct SidebarView: View { if let coordinator { FavoritesTabView( connectionId: connectionId, + databaseType: viewModel.databaseType, sharedSidebarState: sidebarState, tables: tables, coordinator: coordinator 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 2fcf5ee44..77a3c093c 100644 --- a/TableProTests/Core/Menu/MainMenuBuilderTests.swift +++ b/TableProTests/Core/Menu/MainMenuBuilderTests.swift @@ -496,6 +496,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") ] { @@ -511,6 +512,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 new file mode 100644 index 000000000..320d506ad --- /dev/null +++ b/TableProTests/Core/Storage/FavoriteDatabasesStorageTests.swift @@ -0,0 +1,267 @@ +// +// FavoriteDatabasesStorageTests.swift +// TableProTests +// + +import Foundation +import Testing +import TableProSyncTransport + +@testable import TablePro + +@MainActor +@Suite("FavoriteDatabasesStorage") +struct FavoriteDatabasesStorageTests { + 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 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() + 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( + FavoriteDatabasesStorage.syncId(for: development) + == FavoriteDatabasesStorage.syncId(for: production) + ) + } + + @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() + 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) + } + + /// 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: connectionId).map(\.environment) == [.production]) + } + + @Test("Deleting a connection remotely leaves no tombstone to push back") + func remoteConnectionDeleteTracksNothing() throws { + let (storage, _, metadata) = try makeStorage() + let connectionId = UUID() + storage.setFavoriteWithoutSync( + FavoriteDatabaseEntry(connectionId: connectionId, database: "app", environment: .production) + ) + + storage.removeFavoritesWithoutSync(for: connectionId) + + #expect(storage.favorites(for: connectionId).isEmpty) + #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 new file mode 100644 index 000000000..cf3a9b97a --- /dev/null +++ b/TableProTests/Models/FavoriteDatabaseGroupingTests.swift @@ -0,0 +1,110 @@ +// +// 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", .unassigned), + entry("test", .testing) + ], + searchText: "", + filter: .all + ) + + #expect(groups.map(\.environment) == [.development, .testing, .production, .unassigned]) + #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", .unassigned), + 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 name") + func searchMatchesNames() { + let entries: Set = [ + entry("billing", .development), + entry("warehouse", .production) + ] + + let nameMatch = FavoriteDatabaseGrouping.groups( + entries: entries, + searchText: "bill", + filter: .all + ) + + #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: FavoriteDatabaseEnvironment.production.title, + filter: .all + ) + + #expect(byTitle.isEmpty) + } + + @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..55ab1a3da 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) + + SharedSidebarState.removeConnection(id) + SidebarPersistenceKey.removeAll(connectionId: id) + } + @Test("filter text is independent across different connections") @MainActor func filterTextIndependentAcrossConnections() { 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 ff71a2654..8fd393533 100644 --- a/TableProTests/ViewModels/WelcomeViewModelTests.swift +++ b/TableProTests/ViewModels/WelcomeViewModelTests.swift @@ -85,6 +85,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 9061d3f41..6f1dcceec 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, + favoriteDatabaseEnvironments: [String: FavoriteDatabaseEnvironment] = [:], activeDatabase: String? = "app", activeSchema: String? = "public", canReachOtherDatabases: Bool = true, @@ -56,6 +57,7 @@ struct DatabaseTreeMenuSpecTests { schemaEntityNamePlural: "Schemas", objectKindTitles: [.table: "Tables"], isFavorite: isFavorite, + favoriteDatabaseEnvironments: favoriteDatabaseEnvironments, showObjectIcons: true, showObjectComments: false, rowSize: .matchSystem, @@ -289,6 +291,84 @@ 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(.setFavoriteDatabases(databases: ["analytics"], environment: environment))) + } + #expect(!issued.contains(.removeFavoriteDatabases(["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), + favoriteDatabaseEnvironments: ["analytics": .production] + )) + let issued = commands(items) + + #expect(titles(items).contains(String(localized: "Environment"))) + #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 @Test("A menu never opens or closes on a separator, and never doubles one") 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/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 7291d7437..8dc4b7a5a 100644 --- a/TableProTests/Views/Sidebar/SidebarOutlineScaffoldTests.swift +++ b/TableProTests/Views/Sidebar/SidebarOutlineScaffoldTests.swift @@ -670,6 +670,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.favoriteDatabaseEnvironments()["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.favoriteDatabaseEnvironments()["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 f5e69a747..d699d845a 100644 --- a/docs/features/favorites.mdx +++ b/docs/features/favorites.mdx @@ -1,56 +1,81 @@ --- title: Favorites -description: Mark tables as favorites and save frequently used queries with optional keyword shortcuts +description: Pin the databases and tables you keep returning to, save SQL, and link a folder of .sql files --- -The query you retype every Monday is worth saving once. Give it the keyword `dau`, and from then on typing `dau` in the editor expands the whole statement, with the cursor already where you need to fill something in. +The query you retype every Monday is worth saving once. Give it the keyword `dau`: type those three letters in the editor and the whole statement expands, cursor already in place. -The Favorites tab in the sidebar holds two sections, **Tables** for pinned tables and **Queries** for saved SQL, plus a **Team Library** on a Team license and any folders of `.sql` files you have linked. +The Favorites tab holds databases, tables, saved SQL, a **Team Library** on a Team license, and any linked folders of `.sql` files. + +## Database favorites + +| From | How | +|------|-----| +| The sidebar, in Tree layout | Hover a database row and click its star | +| The database switcher | Press `Cmd+K`, then right-click a row | +| The menu bar | **Database > Favorite Database**, on the database you are in | + +Start with the star. It lives only in the Tree layout (**View > Sidebar as Tree**), so use the menu bar in a flat list or from the keyboard. + +The star files the database under **Unassigned**. A right-click sets the environment in one move: **Add to Favorites > Production**. Once a database is a favorite, that submenu reads **Environment**. Right-clicking inside a multiple selection acts on the whole selection. + +In the Favorites tab, databases come first, one row per environment. The **Environment** menu narrows the list to one group; the search field filters by name. Double-click, or press `Return`, to switch to a database. Right-click for **Use as Active Database**, a different environment, or **Remove from Favorites**. `Delete` removes it too. + + + Favorite databases grouped into Development, Testing, and Production in the Favorites sidebar + Favorite databases grouped into Development, Testing, and Production in the Favorites sidebar + + +Database favorites belong to the connection, and go when you delete it. A dropped database stays in the list until you remove it. ## Table favorites -Hover a table row in the sidebar and a star appears at the end of it. Click the star to pin the table; a filled yellow star marks one that is already pinned, so it stays visible without hovering. Favorites move to the top of their section and appear under **Tables** in the Favorites tab. +Hover a table row and click the star at its trailing edge, or right-click and choose **Add to Favorites**. A pinned row keeps its star, filled and yellow. -Double-click a favorite there to open the table. Its right-click menu has **Open Table**, **Show ER Diagram**, and **Remove from Favorites**. +Pinned tables sit under **Tables** in the Favorites tab. Double-click, or press `Return`, to open one. Its right-click menu has **Open Table**, **Show ER Diagram**, and **Remove from Favorites**; `Delete` removes the selection. -Favorites are scoped to the connection, database, and schema, and sync through [iCloud](/features/icloud-sync). One whose table does not exist in the database you are viewing is hidden rather than shown broken. +Table favorites are scoped to the connection, database, and schema, and sync through [iCloud](/features/icloud-sync). One whose table is missing from the database you are browsing is hidden rather than shown broken. ## Recent tables -Turn on **Settings > General > Sidebar > Show recent tables** for a **Recent** section at the top of the sidebar. It records the last 10 tables you opened per connection and database, most recent first; arrowing through preview tabs does not count. Click a row to reopen the table, or right-click to remove one entry or clear the list. Recents stay device-local and survive relaunches. +Turn on **Show recent tables** in [General settings](/customization/general-settings) for a **Recent** section at the top of the object list. It holds the last 10 tables per connection and database, newest first, and stepping through preview tabs records only the one you stop on. + +Click a row to reopen the table. Right-click to remove one entry or clear the list. Recents stay on this Mac. ## Saving a query | From | How | |------|-----| -| The editor toolbar | Click the star above the editor, or press `Cmd+D` | -| Selected SQL | Right-click > **Save as Favorite** | -| [Query history](/features/query-history) | Right-click an entry > **Save as Favorite** | -| The sidebar | **+** in the Favorites tab, then **New Favorite…** | +| The editor | Click the star above it, press `Cmd+D`, or choose **Query > Save as Favorite** | +| Selected SQL | Right-click the selection, then **Save as Favorite** | +| [Query history](/features/query-history) | Right-click an entry, then **Save as Favorite** | +| The Favorites tab | Right-click empty space, then **New Favorite** | -Give it a name, the SQL, and optionally a keyword and a scope. The **+** menu also carries **New Query**, **New Folder**, and **Add Linked SQL Folder…** +The dialog takes a name, the SQL, and an optional keyword. Select **Global** for a query that names nothing specific to one database. Leave it off and the favorite belongs to the connection you are in, and goes with it when that connection is deleted. - Creating a new SQL favorite - Creating a new SQL favorite + A dialog with Name, Query, Keyword fields and a Global checkbox + A dialog with Name, Query, Keyword fields and a Global checkbox -A new favorite belongs to the connection you created it in. Select **Global** in the dialog to make it available in all connections, which is what you want for anything that does not name objects only one database has. Deleting a connection deletes the favorites scoped to it. +There is no plus button. The same right-click menu carries **New Query**, **New Folder**, and **Add Linked SQL Folder…** ## Keywords -A keyword is a shorthand you type in the editor. Type its first letters, case does not matter, and it appears in the completion popup as a starred entry with the favorite's name; `Tab` or `Return` inserts the full SQL. Keywords must be unique inside their scope. +A keyword is what you type instead of the query. Type its first letters, in any case, and the favorite appears in the completion popup with a star; `Tab` or `Return` inserts the SQL. See [Autocomplete](/features/autocomplete). + +Two favorites in one scope cannot share a keyword, and the dialog says so as you type. - Keyword expansion in autocomplete - Keyword expansion in autocomplete + A completion popup with a starred favorite matching the typed keyword + A completion popup with a starred favorite matching the typed keyword Typing a keyword in [Open Quickly](/features/open-quickly) finds the same query without touching the sidebar. ### Cursor placement -Put `;;` in the SQL to say where the cursor lands after expansion. The marker is removed on insert: +Put `;;` in the SQL to say where the cursor lands. The marker is removed on insert: ```sql SELECT COUNT(*) @@ -58,39 +83,43 @@ FROM orders WHERE orders.;; ``` -Accepting that keyword leaves the cursor right after `orders.`, ready for a column name. Only the first `;;` counts; without one the cursor lands at the end. +The cursor lands right after `orders.`, ready for a column name. Only the first `;;` counts; without one it lands at the end. ## Working with saved queries -Double-click a favorite, or select it and press `Return`, to insert it into the editor. That is the default; **Run in New Tab** on the right-click menu runs it instead, and the same menu has **Copy Query**, **Edit…**, **Move to** for filing it in a folder, and **Delete**. Arrow keys move between rows, typing jumps to a name, and `Delete` removes the selection. +Double-click a favorite, or press `Return`, to insert it into the editor. The right-click menu has **Insert in Editor**, **Run in New Tab**, **Copy Query**, **Edit…**, **Move to**, and **Delete**. Arrow keys move between rows, typing jumps to a name, and `Delete` asks first. -Create folders from the **+** menu, rename or delete them by right-clicking, and rename in place on the row. Dragging a saved query or a linked file out of the sidebar drops its SQL into the editor or another app. +Folders come from the same background menu. A folder's own menu has **Rename**, **New Favorite…**, **New Subfolder**, and **Delete Folder**; deleting one moves what was inside up to the parent level. Drag a query or a linked file out of the sidebar to drop its SQL into the editor or another app. ## Team Library -On a Team license the Favorites tab gains a **Team Library** section holding queries your team shared. Double-click one, or select it and press `Return`, to run it in a new tab. To share yours, click **+ > Publish Saved Queries to Team…** See [Team Plan](/features/team). +On a Team license the Favorites tab gains a **Team Library** section holding what your team published. Double-click one, or press `Return`, to run it in a new tab. + +To publish your own, right-click empty space and choose **Publish Saved Queries to Team…** It replaces everything you published before. See [Team Plan](/features/team). ## Linked SQL folders -Link a folder of `.sql` files on disk and they appear in the Favorites sidebar next to your saved queries. The point is a Git repo of shared queries: clone it, link it, and the team's queries stay one click away and current with `git pull`. +Link a folder of `.sql` files on disk and they appear next to your saved queries. The point is a Git repo of shared queries: clone it, link it, and a `git pull` keeps the team current. This is not [Linked Folders](/features/connection-sharing#linked-folders), which watches a folder of shared `.tablepro` connection files. -Click **+** in the Favorites sidebar and choose **Add Linked SQL Folder…** Pick any folder; `.sql`, `.psql`, and `.pgsql` files are indexed, subfolders nest the way they do on disk, and hidden files are skipped. Folders are global, so every connection's Favorites tab shows them, and a large repo is fine: only the first 4 KB of each file is read to build the sidebar. +Right-click empty space, choose **Add Linked SQL Folder…**, and pick any folder. `.sql`, `.psql`, and `.pgsql` are indexed, subfolders nest the way they do on disk, and hidden files are skipped. + +Linked folders are global, so every connection shows them. Only the first 4 KB of each file is read to build the sidebar, so a large repo stays cheap. ### Editing files -Click a linked file to open it as an ordinary editor tab. `Cmd+S` writes back in the file's original encoding, detected on load from UTF-8, UTF-16, ISO Latin-1, and a few others. +Click a linked file to open it as an ordinary editor tab. `Cmd+S` writes back in the encoding the file was read with, detected from the file itself and falling back to UTF-8, then ISO Latin-1. -A file changed on disk after you opened it, by a `git pull` or a merge, shows a yellow banner above the editor with **Reload from Disk**. Save anyway and a side-by-side diff sheet offers **Keep My Changes**, **Reload from Disk**, and **Cancel**. Files added and removed outside TablePro reach the sidebar a second or two later on their own. +A file changed on disk gets the same banner and diff sheet as any other [SQL file](/features/sql-files#when-the-file-changes-underneath-you). Files added and removed outside TablePro reach the sidebar a second later. -A non-UTF-8 file carries a yellow warning triangle in the sidebar. Saving keeps its encoding, and a character that does not fit, an emoji into ISO Latin-1, fails the save with an error rather than losing the character quietly. +A non-UTF-8 file carries a yellow warning triangle. Saving keeps its encoding, and a character it cannot represent, an emoji in an ISO Latin-1 file, fails the save with an error rather than dropping the character. ### Frontmatter -Leading SQL comments set the display name, the keyword, and the tooltip: +Leading SQL comments set the name, the keyword, and the tooltip: ```sql -- @name: Active Users (24h) @@ -107,12 +136,14 @@ WHERE last_seen > NOW() - INTERVAL 24 HOUR; | `@keyword` | Autocomplete trigger, exactly as for a saved favorite. | | `@description` | Shown in tooltips. Optional. | -The parser stops at the first line that is not frontmatter, so these go at the very top; a UTF-8 BOM is handled. A file without frontmatter still appears, under its filename and with no keyword. The `;;` [cursor marker](#cursor-placement) works in linked files too. +The parser stops at the first line that is not one of these, so they go at the very top. Keys are case-insensitive and a UTF-8 BOM is handled. A file without frontmatter appears under its filename with no keyword. The `;;` [cursor marker](#cursor-placement) works here too. -To change frontmatter without opening the file, right-click the row and choose **Edit Metadata…** The dialog rewrites the leading comment block only and keeps the rest of the file and its encoding. +Right-click a row and choose **Edit Metadata…** to change frontmatter without opening the file. The dialog rewrites the leading comment block and leaves the rest, and the encoding, alone. ### Managing linked files -Press `Delete` on a linked file, or right-click > **Move File to Trash**: it goes to the macOS Trash and stays recoverable. Right-click a folder's root row for **Disable**, **Reload**, **Copy Path**, **Show in Finder**, **Add Another SQL Folder…**, and **Remove from Sidebar**. Removing unlinks the folder; the files stay where they are. +Press `Delete` on a linked file, or right-click and choose **Move File to Trash**: it goes to the macOS Trash and stays recoverable. The rest of that menu is **Open in Editor**, **Edit Metadata…**, **Copy Query**, and **Show in Finder**. + +A folder's root row carries **Show in Finder**, **Copy Path**, **Disable**, **Reload**, **Add Another SQL Folder…**, and **Remove from Sidebar**. Removing unlinks the folder and leaves the files where they are. -Linked folder paths are the one part of Favorites that does not sync, so each Mac links its own copy of a shared repo. +Linked folder paths are the one part of Favorites that does not sync, so each Mac links its own copy. diff --git a/docs/features/icloud-sync.mdx b/docs/features/icloud-sync.mdx index 677745577..dff15ecea 100644 --- a/docs/features/icloud-sync.mdx +++ b/docs/features/icloud-sync.mdx @@ -21,6 +21,7 @@ Each synced category has its own toggle under **Sync Categories**. | **Groups & Tags** | Yes | Nested group hierarchy and sort order included | | **SSH Profiles** | Yes | Named [SSH profiles](/connections/ssh-profiles) | | **Table Favorites** | Yes | The names shown in the Favorites tab and pinned in table lists | +| **Database Favorites** | Yes | Favorited databases and their Development / Testing / Production tags | | **Saved Queries** | Yes | Saved SQL and their folders | | **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. The cached file index stays local too | diff --git a/docs/images/favorite-databases-dark.png b/docs/images/favorite-databases-dark.png new file mode 100644 index 000000000..bfd383d31 Binary files /dev/null and b/docs/images/favorite-databases-dark.png differ diff --git a/docs/images/favorite-databases.png b/docs/images/favorite-databases.png new file mode 100644 index 000000000..b284e5766 Binary files /dev/null and b/docs/images/favorite-databases.png differ