From 1b3dd7078c87959a7bfc41db3a75fa5e315b070e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 12 Aug 2026 11:01:00 +0700 Subject: [PATCH 1/3] feat(sidebar): select several databases or schemas and act on them at once --- CHANGELOG.md | 6 + Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift | 1 + .../MSSQLPluginDriver+Schema.swift | 5 + .../PostgreSQLPlugin.swift | 1 + .../PostgreSQLPluginDriver.swift | 4 + .../SurrealDBPlugin.swift | 1 + .../SurrealDBPluginDriver+Schema.swift | 5 + Plugins/TableProPluginKit/DriverPlugin.swift | 2 + .../PluginDatabaseDriver.swift | 6 + TablePro/Core/Database/DatabaseDriver.swift | 7 ++ .../Core/Plugins/PluginDriverAdapter.swift | 4 + .../Plugins/PluginManager+Registration.swift | 9 ++ ...ginMetadataRegistry+RegistryDefaults.swift | 1 + ...inMetadataRegistry+SurrealDBDefaults.swift | 1 + .../Core/Plugins/PluginMetadataRegistry.swift | 6 + .../Query/DatabaseTreeMetadataService.swift | 72 ++++++++--- .../Database/ContainerDropEligibility.swift | 34 +++++ .../Database/DatabaseContainerRef.swift | 58 +++++++++ .../Models/Database/DatabaseDropRequest.swift | 89 +++++++++++++ TablePro/Models/Export/ExportModels.swift | 35 +++++- .../DatabaseSwitcherViewModel.swift | 27 ++-- .../DatabaseSwitcherPopover.swift | 77 +++++++++--- TablePro/Views/Export/ExportDialog.swift | 44 +++++-- .../MainContentCoordinator+Navigation.swift | 83 ++++++++++-- ...ainContentCoordinator+SidebarActions.swift | 8 +- .../Views/Main/MainContentCoordinator.swift | 4 +- TablePro/Views/Main/MainContentView.swift | 47 +++---- TablePro/Views/Sidebar/DatabaseTreeNode.swift | 18 +++ .../DatabaseTreeOutlineCoordinator.swift | 38 ++++-- .../Views/Sidebar/DatabaseTreeRowView.swift | 118 +++++++++++++++--- .../Views/Sidebar/SidebarContextMenu.swift | 7 +- .../Views/Sidebar/SidebarMenuTarget.swift | 24 ++++ .../Autocomplete/SQLSchemaProviderTests.swift | 9 +- .../DatabaseTreeMetadataServiceTests.swift | 53 ++++++++ .../ContainerDropEligibilityTests.swift | 103 +++++++++++++++ .../Database/DatabaseDropRequestTests.swift | 93 ++++++++++++++ .../Export/ExportPreselectionTests.swift | 72 +++++++++++ .../Sidebar/SidebarMenuTargetTests.swift | 70 +++++++++++ docs/features/table-operations.mdx | 25 +++- 39 files changed, 1137 insertions(+), 130 deletions(-) create mode 100644 TablePro/Models/Database/ContainerDropEligibility.swift create mode 100644 TablePro/Models/Database/DatabaseContainerRef.swift create mode 100644 TablePro/Models/Database/DatabaseDropRequest.swift create mode 100644 TablePro/Views/Sidebar/SidebarMenuTarget.swift create mode 100644 TableProTests/Models/Database/ContainerDropEligibilityTests.swift create mode 100644 TableProTests/Models/Database/DatabaseDropRequestTests.swift create mode 100644 TableProTests/Models/Export/ExportPreselectionTests.swift create mode 100644 TableProTests/Views/Sidebar/SidebarMenuTargetTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index e18fc5a53..fd3920842 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Select several databases or schemas in the sidebar tree and act on them at once: drop, refresh, copy names, or export. Shift-click and Cmd-click extend the selection. +- The database switcher takes a multiple selection too, with the same actions on the right-click menu. +- Drop Schema for PostgreSQL, SQL Server and SurrealDB. - SSL settings on mobile for MySQL, PostgreSQL and Redis: a mode picker plus CA, client certificate and client key. Import a PEM file, paste one, or use a PKCS#12 file. (#2083) - Legacy UUID Encoding on a MongoDB connection, so binary UUIDs written by the Java, C# or Python drivers read as UUIDs instead of hex. Filters, edits and MQL exports write the same bytes back. (#2086) @@ -44,6 +47,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Right-clicking a table that is not part of the current selection now acts on that table. It used to act on the selected tables instead, including for Delete. +- The sidebar drops a database from the tree as soon as you drop it on the server, instead of listing it until you reconnect. +- Refreshing the database list keeps the current list on screen while it reloads, and keeps it if the reload fails. - MQL export writes a MongoDB `_id` as `ObjectId("...")` and a date as `ISODate("...")`, so running the script inserts the same types back instead of strings. A value nested inside a subdocument is still exported as a string. - MQL export keeps a binary value's BSON subtype and writes it as a `BinData(...)` constructor, so running the script inserts the same bytes back. It wrote Extended JSON that mongosh reads as a plain object, and stamped every value as subtype 0. (#2086) - MongoDB no longer prints a binary field nested inside a document as a UUID when it is not one, or labels a UUID with the wrong byte order. (#2086) diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index ec9f028a8..f1ca3fe6e 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -186,6 +186,7 @@ final class MSSQLPlugin: NSObject, TableProPlugin, DriverPlugin { ) static let supportsDropDatabase = true + static let supportsDropSchema = true static let supportsTriggers = true static let supportsTriggerEditing = true diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift b/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift index ac8e3b400..eac22c893 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift @@ -569,6 +569,11 @@ extension MSSQLPluginDriver { _ = try await execute(query: "DROP DATABASE \(quotedName)") } + func dropSchema(name: String) async throws { + let quotedName = "[\(name.replacingOccurrences(of: "]", with: "]]"))]" + _ = try await execute(query: "DROP SCHEMA \(quotedName)") + } + // MARK: - All Tables Metadata func allTablesMetadataSQL(schema: String?) -> String? { diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift index 4a8880d2a..b4e5900fb 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift @@ -76,6 +76,7 @@ final class PostgreSQLPlugin: NSObject, TableProPlugin, DriverPlugin { static let requiresReconnectForDatabaseSwitch = true static let parameterStyle: ParameterStyle = .dollar static let supportsDropDatabase = true + static let supportsDropSchema = true static let supportsTriggers = true static let supportsTriggerEditing = true diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index b8c61a650..277e64e52 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -981,6 +981,10 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { _ = try await execute(query: "DROP DATABASE \(quoteIdentifier(name))") } + func dropSchema(name: String) async throws { + _ = try await execute(query: "DROP SCHEMA \(quoteIdentifier(name)) CASCADE") + } + private struct Template1Defaults { let collate: String let ctype: String diff --git a/Plugins/SurrealDBDriverPlugin/SurrealDBPlugin.swift b/Plugins/SurrealDBDriverPlugin/SurrealDBPlugin.swift index 3ea68950c..9a3a5244b 100644 --- a/Plugins/SurrealDBDriverPlugin/SurrealDBPlugin.swift +++ b/Plugins/SurrealDBDriverPlugin/SurrealDBPlugin.swift @@ -41,6 +41,7 @@ final class SurrealDBPlugin: NSObject, TableProPlugin, DriverPlugin { static let defaultPrimaryKeyColumn: String? = "id" static let immutableColumns: [String] = ["id"] static let supportsDropDatabase = true + static let supportsDropSchema = true static let postConnectActions: [PostConnectAction] = [.selectSchemaFromLastSession] static let structureColumnFields: [StructureColumnField] = [.name, .type, .nullable] diff --git a/Plugins/SurrealDBDriverPlugin/SurrealDBPluginDriver+Schema.swift b/Plugins/SurrealDBDriverPlugin/SurrealDBPluginDriver+Schema.swift index 6a64ae64a..abad6e9fe 100644 --- a/Plugins/SurrealDBDriverPlugin/SurrealDBPluginDriver+Schema.swift +++ b/Plugins/SurrealDBDriverPlugin/SurrealDBPluginDriver+Schema.swift @@ -42,6 +42,11 @@ extension SurrealDBPluginDriver { _ = try await run(statement, scope: SurrealScope(namespace: nil, database: nil)) } + func dropSchema(name: String) async throws { + let statement = "REMOVE DATABASE " + SurrealQL.quoteIdentifier(name) + ";" + _ = try await run(statement, scope: SurrealScope(namespace: currentNamespace, database: nil)) + } + // MARK: - Tables func fetchTables(schema: String?) async throws -> [PluginTableInfo] { diff --git a/Plugins/TableProPluginKit/DriverPlugin.swift b/Plugins/TableProPluginKit/DriverPlugin.swift index ee5fb1650..a6b703581 100644 --- a/Plugins/TableProPluginKit/DriverPlugin.swift +++ b/Plugins/TableProPluginKit/DriverPlugin.swift @@ -60,6 +60,7 @@ public protocol DriverPlugin: TableProPlugin { static var postConnectActions: [PostConnectAction] { get } static var parameterStyle: ParameterStyle { get } static var supportsDropDatabase: Bool { get } + static var supportsDropSchema: Bool { get } static var supportsAddColumn: Bool { get } static var supportsModifyColumn: Bool { get } @@ -136,6 +137,7 @@ public extension DriverPlugin { static var isDownloadable: Bool { false } static var postConnectActions: [PostConnectAction] { [] } static var supportsDropDatabase: Bool { false } + static var supportsDropSchema: Bool { false } static var supportsAddColumn: Bool { true } static var supportsModifyColumn: Bool { true } diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index 17d5048a1..e59a7e130 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -122,6 +122,7 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { func createDatabaseFormSpec() async throws -> PluginCreateDatabaseFormSpec? func createDatabase(_ request: PluginCreateDatabaseRequest) async throws func dropDatabase(name: String) async throws + func dropSchema(name: String) async throws func executeParameterized(query: String, parameters: [PluginCellValue]) async throws -> PluginQueryResult // Session contexts (optional, switchable session dimensions such as a warehouse or role) @@ -328,6 +329,11 @@ public extension PluginDatabaseDriver { userInfo: [NSLocalizedDescriptionKey: "Drop database is not supported by this driver"]) } + func dropSchema(name: String) async throws { + throw NSError(domain: "PluginDatabaseDriver", code: -1, + userInfo: [NSLocalizedDescriptionKey: "Drop schema is not supported by this driver"]) + } + func switchDatabase(to database: String) async throws { throw NSError( domain: "TableProPluginKit", diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 14b3e4431..0f924ce40 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -170,6 +170,8 @@ protocol DatabaseDriver: AnyObject, Sendable { func dropDatabase(name: String) async throws + func dropSchema(name: String) async throws + func fetchSessionContexts() async throws -> [PluginSessionContext]? func switchSessionContext(id: String, to value: String) async throws @@ -301,6 +303,11 @@ extension DatabaseDriver { userInfo: [NSLocalizedDescriptionKey: "Drop database is not supported by this driver"]) } + func dropSchema(name: String) async throws { + throw NSError(domain: "DatabaseDriver", code: -1, + userInfo: [NSLocalizedDescriptionKey: "Drop schema is not supported by this driver"]) + } + func createDatabaseFormSpec() async throws -> CreateDatabaseFormSpec? { nil } func fetchSessionContexts() async throws -> [PluginSessionContext]? { nil } diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index 794f9e7af..903af4c37 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -464,6 +464,10 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { try await pluginDriver.dropDatabase(name: name) } + func dropSchema(name: String) async throws { + try await pluginDriver.dropSchema(name: name) + } + func fetchSessionContexts() async throws -> [PluginSessionContext]? { try await pluginDriver.fetchSessionContexts() } diff --git a/TablePro/Core/Plugins/PluginManager+Registration.swift b/TablePro/Core/Plugins/PluginManager+Registration.swift index 09729e9dc..de36be475 100644 --- a/TablePro/Core/Plugins/PluginManager+Registration.swift +++ b/TablePro/Core/Plugins/PluginManager+Registration.swift @@ -416,6 +416,10 @@ extension PluginManager { .schema.schemaEntityName ?? "Schema" } + func schemaEntityNamePlural(for databaseType: DatabaseType) -> String { + schemaEntityName(for: databaseType) + "s" + } + func supportsCascadeDrop(for databaseType: DatabaseType) -> Bool { PluginMetadataRegistry.shared.snapshot(forTypeId: databaseType.pluginTypeId)? .capabilities.supportsCascadeDrop ?? false @@ -491,6 +495,11 @@ extension PluginManager { .capabilities.supportsDropDatabase ?? false } + func supportsDropSchema(for databaseType: DatabaseType) -> Bool { + PluginMetadataRegistry.shared.snapshot(forTypeId: databaseType.pluginTypeId)? + .capabilities.supportsDropSchema ?? false + } + func autoLimitStyle(for databaseType: DatabaseType) -> AutoLimitStyle { guard let snapshot = PluginMetadataRegistry.shared.snapshot(forTypeId: databaseType.pluginTypeId) else { return .limit diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index 09f7a3343..e0ae16e30 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -191,6 +191,7 @@ extension PluginMetadataRegistry { supportsQueryProgress: false, requiresReconnectForDatabaseSwitch: false, supportsDropDatabase: true, + supportsDropSchema: true, supportsRenameColumn: true, defaultSSLMode: .preferred ), diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+SurrealDBDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+SurrealDBDefaults.swift index a8b43cff8..1f48dbc2d 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+SurrealDBDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+SurrealDBDefaults.swift @@ -33,6 +33,7 @@ extension PluginMetadataRegistry { supportsQueryProgress: false, requiresReconnectForDatabaseSwitch: false, supportsDropDatabase: true, + supportsDropSchema: true, supportsOpportunisticTLS: false ), schema: PluginMetadataSnapshot.SchemaInfo( diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift index cbaffdc3f..3f0505c4c 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -48,6 +48,7 @@ struct PluginMetadataSnapshot: Sendable { let requiresReconnectForDatabaseSwitch: Bool let supportsDropDatabase: Bool // `var` with defaults so existing call sites compile without passing these fields + var supportsDropSchema: Bool = false var supportsAddColumn: Bool = true var supportsModifyColumn: Bool = true var supportsDropColumn: Bool = true @@ -642,6 +643,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsQueryProgress: false, requiresReconnectForDatabaseSwitch: true, supportsDropDatabase: true, + supportsDropSchema: true, supportsRenameColumn: true, supportsTriggers: true, supportsTriggerEditing: true, @@ -695,6 +697,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsQueryProgress: false, requiresReconnectForDatabaseSwitch: true, supportsDropDatabase: true, + supportsDropSchema: true, defaultSSLMode: .preferred ), schema: PluginMetadataSnapshot.SchemaInfo( @@ -749,6 +752,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsQueryProgress: false, requiresReconnectForDatabaseSwitch: true, supportsDropDatabase: true, + supportsDropSchema: true, supportsAddColumn: false, supportsModifyColumn: false, supportsDropColumn: false, @@ -805,6 +809,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsQueryProgress: false, requiresReconnectForDatabaseSwitch: true, supportsDropDatabase: true, + supportsDropSchema: true, supportsRenameColumn: true, supportsTriggers: true, supportsTriggerEditing: true, @@ -1105,6 +1110,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsQueryProgress: driverType.supportsQueryProgress, requiresReconnectForDatabaseSwitch: driverType.requiresReconnectForDatabaseSwitch, supportsDropDatabase: driverType.supportsDropDatabase, + supportsDropSchema: driverType.supportsDropSchema, supportsAddColumn: driverType.supportsAddColumn, supportsModifyColumn: driverType.supportsModifyColumn, supportsDropColumn: driverType.supportsDropColumn, diff --git a/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift index e09e3ac68..dd66e5933 100644 --- a/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift +++ b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift @@ -98,15 +98,8 @@ final class DatabaseTreeMetadataService { case .idle, .failed: break } databaseList[connectionId] = .loading - let systemNames = Set(PluginManager.shared.systemDatabaseNames(for: databaseType)) do { - let list = try await databaseDedup.execute(key: connectionId) { [self] in - try await withDriver(connectionId: connectionId, database: nil) { driver in - try await driver.fetchDatabases().sorted().map { - DatabaseMetadata.minimal(name: $0, isSystem: systemNames.contains($0)) - } - } - } + let list = try await fetchDatabaseList(connectionId: connectionId, databaseType: databaseType) databaseList[connectionId] = .loaded(list) } catch is CancellationError { if case .loading = databaseList[connectionId] { databaseList[connectionId] = .idle } @@ -116,6 +109,17 @@ final class DatabaseTreeMetadataService { } } + private func fetchDatabaseList(connectionId: UUID, databaseType: DatabaseType) async throws -> [DatabaseMetadata] { + let systemNames = Set(PluginManager.shared.systemDatabaseNames(for: databaseType)) + return try await databaseDedup.execute(key: connectionId) { [self] in + try await withDriver(connectionId: connectionId, database: nil) { driver in + try await driver.fetchDatabases().sorted().map { + DatabaseMetadata.minimal(name: $0, isSystem: systemNames.contains($0)) + } + } + } + } + func loadSchemas(connectionId: UUID, database: String) async { guard isConnected(connectionId) else { return } let key = DatabaseKey(connectionId: connectionId, database: database) @@ -125,11 +129,7 @@ final class DatabaseTreeMetadataService { } schemaList[key] = .loading do { - let list = try await schemaDedup.execute(key: key) { [self] in - try await withDriver(connectionId: connectionId, database: database) { driver in - try await driver.fetchSchemas() - } - } + let list = try await fetchSchemaList(connectionId: connectionId, database: database, key: key) schemaList[key] = .loaded(list) } catch is CancellationError { if case .loading = schemaList[key] { schemaList[key] = .idle } @@ -139,6 +139,14 @@ final class DatabaseTreeMetadataService { } } + private func fetchSchemaList(connectionId: UUID, database: String, key: DatabaseKey) async throws -> [String] { + try await schemaDedup.execute(key: key) { [self] in + try await withDriver(connectionId: connectionId, database: database) { driver in + try await driver.fetchSchemas() + } + } + } + func loadTables(connectionId: UUID, database: String, schema: String?) async { guard isConnected(connectionId) else { return } let key = Self.objectsKey(connectionId: connectionId, database: database, schema: schema) @@ -226,17 +234,47 @@ final class DatabaseTreeMetadataService { // MARK: - Refresh + /// Fetches first and commits over the old list, so a refresh never empties the tree + /// and a failed refresh keeps the databases already on screen. func refreshDatabases(connectionId: UUID, databaseType: DatabaseType) async { await databaseDedup.cancel(key: connectionId) - databaseList.removeValue(forKey: connectionId) - await loadDatabases(connectionId: connectionId, databaseType: databaseType) + guard case .loaded = databaseListState(for: connectionId) else { + databaseList.removeValue(forKey: connectionId) + await loadDatabases(connectionId: connectionId, databaseType: databaseType) + return + } + guard isConnected(connectionId) else { return } + do { + databaseList[connectionId] = .loaded( + try await fetchDatabaseList(connectionId: connectionId, databaseType: databaseType) + ) + } catch is CancellationError { + } catch { + Self.logger.warning( + "databases refresh failed connId=\(connectionId, privacy: .public) error=\(error.localizedDescription, privacy: .public)" + ) + } } func refreshSchemas(connectionId: UUID, database: String) async { let key = DatabaseKey(connectionId: connectionId, database: database) await schemaDedup.cancel(key: key) - schemaList.removeValue(forKey: key) - await loadSchemas(connectionId: connectionId, database: database) + guard case .loaded = schemaList[key] ?? .idle else { + schemaList.removeValue(forKey: key) + await loadSchemas(connectionId: connectionId, database: database) + return + } + guard isConnected(connectionId) else { return } + do { + schemaList[key] = .loaded( + try await fetchSchemaList(connectionId: connectionId, database: database, key: key) + ) + } catch is CancellationError { + } catch { + Self.logger.warning( + "schemas refresh failed db=\(database, privacy: .public) error=\(error.localizedDescription, privacy: .public)" + ) + } } func refreshObjects(connectionId: UUID, database: String, schema: String?) async { diff --git a/TablePro/Models/Database/ContainerDropEligibility.swift b/TablePro/Models/Database/ContainerDropEligibility.swift new file mode 100644 index 000000000..124eaf609 --- /dev/null +++ b/TablePro/Models/Database/ContainerDropEligibility.swift @@ -0,0 +1,34 @@ +// +// ContainerDropEligibility.swift +// TablePro +// + +import Foundation + +enum ContainerDropEligibility { + struct Context { + let activeDatabase: String? + let activeSchema: String? + let supportsDropDatabase: Bool + let supportsDropSchema: Bool + let isReadOnly: Bool + } + + static func droppable(_ targets: [DatabaseContainerRef], context: Context) -> [DatabaseContainerRef] { + guard !context.isReadOnly else { return [] } + return targets.filter { isDroppable($0, context: context) } + } + + private static func isDroppable(_ target: DatabaseContainerRef, context: Context) -> Bool { + guard !target.isSystem else { return false } + switch target.kind { + case .database: + guard context.supportsDropDatabase else { return false } + return target.database != context.activeDatabase + case .schema: + guard context.supportsDropSchema else { return false } + guard target.database == context.activeDatabase else { return true } + return target.schema != context.activeSchema + } + } +} diff --git a/TablePro/Models/Database/DatabaseContainerRef.swift b/TablePro/Models/Database/DatabaseContainerRef.swift new file mode 100644 index 000000000..557d6d9aa --- /dev/null +++ b/TablePro/Models/Database/DatabaseContainerRef.swift @@ -0,0 +1,58 @@ +// +// DatabaseContainerRef.swift +// TablePro +// + +import Foundation + +struct DatabaseContainerRef: Hashable, Identifiable { + enum Kind: Hashable { + case database + case schema + } + + let kind: Kind + let database: String + let schema: String? + let isSystem: Bool + + var id: String { + switch kind { + case .database: "database\u{1}\(database)" + case .schema: "schema\u{1}\(database)\u{1}\(schema ?? "")" + } + } + + var name: String { + switch kind { + case .database: database + case .schema: schema ?? database + } + } + + static func database(_ name: String, isSystem: Bool = false) -> DatabaseContainerRef { + DatabaseContainerRef(kind: .database, database: name, schema: nil, isSystem: isSystem) + } + + static func schema(database: String, schema: String, isSystem: Bool = false) -> DatabaseContainerRef { + DatabaseContainerRef(kind: .schema, database: database, schema: schema, isSystem: isSystem) + } + + static func == (lhs: DatabaseContainerRef, rhs: DatabaseContainerRef) -> Bool { + lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + +extension [DatabaseContainerRef] { + var sortedByName: [DatabaseContainerRef] { + sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + } + + func matching(kind: DatabaseContainerRef.Kind) -> [DatabaseContainerRef] { + filter { $0.kind == kind } + } +} diff --git a/TablePro/Models/Database/DatabaseDropRequest.swift b/TablePro/Models/Database/DatabaseDropRequest.swift new file mode 100644 index 000000000..33f6bcd3f --- /dev/null +++ b/TablePro/Models/Database/DatabaseDropRequest.swift @@ -0,0 +1,89 @@ +// +// DatabaseDropRequest.swift +// TablePro +// + +import Foundation + +struct DatabaseDropRequest: Identifiable, Equatable { + static let maxListedNames = 10 + + let id: String + let targets: [DatabaseContainerRef] + let entityName: String + let entityNamePlural: String + let dropsDependentObjects: Bool + + init( + targets: [DatabaseContainerRef], + entityName: String, + entityNamePlural: String, + dropsDependentObjects: Bool = false + ) { + self.targets = targets.sortedByName + self.entityName = entityName + self.entityNamePlural = entityNamePlural + self.dropsDependentObjects = dropsDependentObjects + self.id = self.targets.map(\.id).joined(separator: "\u{1}") + } + + var kind: DatabaseContainerRef.Kind { + targets.first?.kind ?? .database + } + + var names: [String] { + targets.map(\.name) + } + + var isEmpty: Bool { + targets.isEmpty + } + + var title: String { + guard targets.count != 1 else { + return String( + format: String(localized: "Drop %1$@ “%2$@”?"), + entityName.lowercased(), + targets[0].name + ) + } + return String( + format: String(localized: "Drop %1$lld %2$@?"), + targets.count, + entityNamePlural.lowercased() + ) + } + + var menuTitle: String { + guard targets.count != 1 else { + return String(format: String(localized: "Drop %1$@ “%2$@”…"), entityName, targets[0].name) + } + return String(format: String(localized: "Drop %1$lld %2$@…"), targets.count, entityNamePlural) + } + + var confirmButtonTitle: String { + guard targets.count != 1 else { + return String(format: String(localized: "Drop %@"), entityName) + } + return String(format: String(localized: "Drop %1$lld %2$@"), targets.count, entityNamePlural) + } + + var message: String { + var lines: [String] = [] + if targets.count > 1 { + lines.append(listedNames) + } + lines.append(String(localized: "All tables and data will be permanently deleted.")) + if dropsDependentObjects { + lines.append(String(localized: "Objects that depend on them will be dropped too.")) + } + return lines.joined(separator: "\n\n") + } + + private var listedNames: String { + let listed = names.prefix(Self.maxListedNames).joined(separator: ", ") + let overflow = names.count - Self.maxListedNames + guard overflow > 0 else { return listed } + return listed + String(format: String(localized: ", and %lld more"), overflow) + } +} diff --git a/TablePro/Models/Export/ExportModels.swift b/TablePro/Models/Export/ExportModels.swift index 2800fc29d..5fce969d0 100644 --- a/TablePro/Models/Export/ExportModels.swift +++ b/TablePro/Models/Export/ExportModels.swift @@ -8,9 +8,42 @@ import TableProPluginKit // MARK: - Export Mode +/// What the export dialog starts with selected: named tables inside the current +/// container, or every table of whole databases or schemas. +enum ExportPreselection: Equatable { + case tables(Set) + case containers([DatabaseContainerRef]) + + func selects(table: String, inContainer container: String, isCurrentContainer: Bool) -> Bool { + switch self { + case .tables(let names): + return isCurrentContainer && names.contains(table) + case .containers(let refs): + return refs.contains { $0.name == container } + } + } + + var singleTableName: String? { + guard case .tables(let names) = self, names.count == 1 else { return nil } + return names.first + } + + var containerNames: [String] { + guard case .containers(let refs) = self else { return [] } + return refs.map(\.name) + } + + /// The export dialog lists the schemas of the connected database only, so a schema + /// belonging to another database has nothing to preselect there. + static func canPreselect(containers: [DatabaseContainerRef], activeDatabase: String?) -> Bool { + guard !containers.isEmpty else { return false } + return containers.allSatisfy { $0.kind == .database || $0.database == activeDatabase } + } +} + /// Defines the export mode: either exporting database tables or in-memory query results. enum ExportMode { - case tables(connection: DatabaseConnection, preselectedTables: Set) + case tables(connection: DatabaseConnection, preselection: ExportPreselection) case queryResults(connection: DatabaseConnection, tableRows: TableRows, suggestedFileName: String) case streamingQuery(connection: DatabaseConnection, query: String, suggestedFileName: String) } diff --git a/TablePro/ViewModels/DatabaseSwitcherViewModel.swift b/TablePro/ViewModels/DatabaseSwitcherViewModel.swift index c029d790a..5e579d9ef 100644 --- a/TablePro/ViewModels/DatabaseSwitcherViewModel.swift +++ b/TablePro/ViewModels/DatabaseSwitcherViewModel.swift @@ -16,7 +16,14 @@ final class DatabaseSwitcherViewModel { var searchText = "" { didSet { selectedDatabase = filteredDatabases.first?.name } } - var selectedDatabase: String? + var selectedDatabases: Set = [] + + /// The keyboard path (arrows, Return) drives one row at a time, so it reads and + /// writes the selection as a single value while the mouse can extend it. + var selectedDatabase: String? { + get { selectedDatabases.count == 1 ? selectedDatabases.first : nil } + set { selectedDatabases = newValue.map { [$0] } ?? [] } + } var isLoading = false var errorMessage: String? var showPreview = false @@ -123,17 +130,19 @@ final class DatabaseSwitcherViewModel { try await driver.createDatabase(request) } - func dropDatabase(name: String) async throws { - guard let driver = services.databaseManager.driver(for: connectionId) else { - throw DatabaseError.notConnected - } - try await driver.dropDatabase(name: name) + /// The selected row the keyboard acts from, in the order the list shows them. + var primarySelection: String? { + filteredDatabases.first { selectedDatabases.contains($0.name) }?.name + } + + var selectedMetadata: [DatabaseMetadata] { + filteredDatabases.filter { selectedDatabases.contains($0.name) } } func moveUp() { let items = filteredDatabases guard !items.isEmpty else { return } - guard let current = selectedDatabase, + guard let current = primarySelection, let index = items.firstIndex(where: { $0.name == current }), index > 0 else { return } @@ -143,12 +152,12 @@ final class DatabaseSwitcherViewModel { func moveDown() { let items = filteredDatabases guard !items.isEmpty else { return } - if let current = selectedDatabase, + if let current = primarySelection, let index = items.firstIndex(where: { $0.name == current }), index < items.count - 1 { selectedDatabase = items[index + 1].name - } else if selectedDatabase == nil { + } else if primarySelection == nil { selectedDatabase = items.first?.name } } diff --git a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift index 876bccaab..817e4be0e 100644 --- a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift +++ b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift @@ -19,14 +19,18 @@ struct DatabaseSwitcherPopoverHost: View { currentDatabase: activeContainer, databaseType: connection.type, connectionId: connection.id, + isReadOnly: coordinator.safeModeLevel.blocksAllWrites, onSelect: { [weak coordinator] container in Task { await coordinator?.switchContainer(to: container) } }, onRequestCreate: { [weak coordinator] in coordinator?.activeSheet = .createDatabase }, - onRequestDrop: { [weak coordinator] name in - coordinator?.databaseToDrop = name + onRequestDrop: { [weak coordinator] containers in + coordinator?.requestContainerDrop(containers) + }, + onRequestExport: { [weak coordinator] containers in + coordinator?.openExportDialog(containers: containers) } ) } else { @@ -39,9 +43,11 @@ struct DatabaseSwitcherPopover: View { let currentDatabase: String? let databaseType: DatabaseType let connectionId: UUID + let isReadOnly: Bool let onSelect: (String) -> Void let onRequestCreate: () -> Void - let onRequestDrop: (String) -> Void + let onRequestDrop: ([DatabaseContainerRef]) -> Void + let onRequestExport: ([DatabaseContainerRef]) -> Void @Environment(\.dismiss) private var dismiss @State private var viewModel: DatabaseSwitcherViewModel @@ -67,16 +73,20 @@ struct DatabaseSwitcherPopover: View { currentDatabase: String?, databaseType: DatabaseType, connectionId: UUID, + isReadOnly: Bool, onSelect: @escaping (String) -> Void, onRequestCreate: @escaping () -> Void, - onRequestDrop: @escaping (String) -> Void + onRequestDrop: @escaping ([DatabaseContainerRef]) -> Void, + onRequestExport: @escaping ([DatabaseContainerRef]) -> Void ) { self.currentDatabase = currentDatabase self.databaseType = databaseType self.connectionId = connectionId + self.isReadOnly = isReadOnly self.onSelect = onSelect self.onRequestCreate = onRequestCreate self.onRequestDrop = onRequestDrop + self.onRequestExport = onRequestExport self._viewModel = State( wrappedValue: DatabaseSwitcherViewModel( connectionId: connectionId, @@ -143,7 +153,7 @@ struct DatabaseSwitcherPopover: View { private var list: some View { ScrollViewReader { proxy in - List(selection: $viewModel.selectedDatabase) { + List(selection: $viewModel.selectedDatabases) { ForEach(viewModel.filteredDatabases) { db in row(for: db) } @@ -158,7 +168,7 @@ struct DatabaseSwitcherPopover: View { viewModel.selectedDatabase = name commitSelection() } - .onChange(of: viewModel.selectedDatabase) { _, newValue in + .onChange(of: viewModel.primarySelection) { _, newValue in guard let item = newValue else { return } withAnimation(.easeInOut(duration: 0.15)) { proxy.scrollTo(item) @@ -198,20 +208,59 @@ struct DatabaseSwitcherPopover: View { @ViewBuilder private func contextMenuItems(for selection: Set) -> some View { - if supportsDropDatabase, - let name = selection.first, - let database = viewModel.filteredDatabases.first(where: { $0.name == name }), - !database.isSystemDatabase, - database.name != currentDatabase { + let targets = containerRefs(for: selection) + let droppable = ContainerDropEligibility.droppable(targets, context: dropEligibilityContext) + + if !targets.isEmpty { + Button(targets.count == 1 + ? String(localized: "Copy Name") + : String(format: String(localized: "Copy %lld Names"), targets.count) + ) { + ClipboardService.shared.writeText(targets.map(\.name).joined(separator: ",")) + } + + Button(String(localized: "Export…")) { + dismiss() + onRequestExport(targets) + } + } + + if !droppable.isEmpty { + Divider() + Button(role: .destructive) { dismiss() - onRequestDrop(database.name) + onRequestDrop(droppable) } label: { - Label(String(format: String(localized: "Drop %@…"), containerName), systemImage: "trash") + Label(dropMenuTitle(for: droppable), systemImage: "trash") } } } + private func containerRefs(for selection: Set) -> [DatabaseContainerRef] { + viewModel.filteredDatabases + .filter { selection.contains($0.name) } + .map { .database($0.name, isSystem: $0.isSystemDatabase) } + } + + private func dropMenuTitle(for targets: [DatabaseContainerRef]) -> String { + DatabaseDropRequest( + targets: targets, + entityName: containerName, + entityNamePlural: containerNamePlural + ).menuTitle + } + + private var dropEligibilityContext: ContainerDropEligibility.Context { + ContainerDropEligibility.Context( + activeDatabase: currentDatabase, + activeSchema: nil, + supportsDropDatabase: supportsDropDatabase, + supportsDropSchema: false, + isReadOnly: isReadOnly + ) + } + private var loadingView: some View { VStack(spacing: 10) { ProgressView().controlSize(.small) @@ -302,7 +351,7 @@ struct DatabaseSwitcherPopover: View { } private func commitSelection() { - guard let name = viewModel.selectedDatabase else { return } + guard let name = viewModel.primarySelection else { return } if name == currentDatabase { dismiss() return diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index d54cc300c..32f9385cf 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -61,11 +61,11 @@ struct ExportDialog: View { return 0 } - private var preselectedTables: Set { - if case .tables(_, let tables) = mode { - return tables + private var preselection: ExportPreselection { + if case .tables(_, let preselection) = mode { + return preselection } - return [] + return .tables([]) } // MARK: - Body @@ -565,7 +565,11 @@ struct ExportDialog: View { name: table.name, databaseName: "", type: table.type, - isSelected: preselectedTables.contains(table.name) + isSelected: preselection.selects( + table: table.name, + inContainer: dbName, + isCurrentContainer: true + ) ) } let item = ExportDatabaseItem( @@ -619,7 +623,11 @@ struct ExportDialog: View { let tableItems = tables.map { table in let priorRow = priorRows["\(schema).\(table.name)"] let selected = priorRow?.isSelected - ?? (isDefaultSchema && preselectedTables.contains(table.name)) + ?? preselection.selects( + table: table.name, + inContainer: schema, + isCurrentContainer: isDefaultSchema + ) return ExportTableItem( name: table.name, databaseName: schema, @@ -632,7 +640,7 @@ struct ExportDialog: View { items.append(ExportDatabaseItem( name: schema, tables: tableItems, - isExpanded: isDefaultSchema + isExpanded: isDefaultSchema || preselection.containerNames.contains(schema) )) } } @@ -659,7 +667,11 @@ struct ExportDialog: View { let tableItems = tables.map { table in let priorRow = priorRows["\(dbName).\(table.name)"] let selected = priorRow?.isSelected - ?? (isCurrentDB && preselectedTables.contains(table.name)) + ?? preselection.selects( + table: table.name, + inContainer: dbName, + isCurrentContainer: isCurrentDB + ) return ExportTableItem( name: table.name, databaseName: dbName, @@ -672,7 +684,7 @@ struct ExportDialog: View { items.append(ExportDatabaseItem( name: dbName, tables: tableItems, - isExpanded: isCurrentDB + isExpanded: isCurrentDB || preselection.containerNames.contains(dbName) )) } } @@ -689,8 +701,10 @@ struct ExportDialog: View { ) isLoading = false - if preselectedTables.count == 1, let first = preselectedTables.first { - config.fileName = first + if let singleTable = preselection.singleTableName { + config.fileName = singleTable + } else if preselection.containerNames.count == 1, let container = preselection.containerNames.first { + config.fileName = container } else if !connection.database.isEmpty { config.fileName = connection.database } @@ -717,7 +731,11 @@ struct ExportDialog: View { name: table.name, databaseName: "", type: table.type, - isSelected: priorRow?.isSelected ?? preselectedTables.contains(table.name), + isSelected: priorRow?.isSelected ?? preselection.selects( + table: table.name, + inContainer: name, + isCurrentContainer: true + ), optionValues: priorRow?.optionValues ?? [] ) } @@ -936,6 +954,6 @@ struct ExportDialog: View { return ExportDialog( isPresented: .constant(true), - mode: .tables(connection: connection, preselectedTables: ["users"]) + mode: .tables(connection: connection, preselection: .tables(["users"])) ) } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index c103e716a..885a28c76 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -494,23 +494,80 @@ extension MainContentCoordinator { } } - /// Drop a database. Called from the database switcher's confirmation dialog. - func dropDatabase(name: String) async { - guard let driver = DatabaseManager.shared.driver(for: connectionId) else { - navigationLogger.warning("dropDatabase(name: \(name, privacy: .public)) ignored: no active driver") - return + func requestContainerDrop(_ targets: [DatabaseContainerRef]) { + guard !targets.isEmpty else { return } + let isSchema = targets.contains { $0.kind == .schema } + containerDropRequest = DatabaseDropRequest( + targets: targets, + entityName: isSchema + ? PluginManager.shared.schemaEntityName(for: connection.type) + : PluginManager.shared.containerEntityName(for: connection.type), + entityNamePlural: isSchema + ? PluginManager.shared.schemaEntityNamePlural(for: connection.type) + : PluginManager.shared.containerEntityNamePlural(for: connection.type), + dropsDependentObjects: isSchema + ) + } + + /// Drop every container in the request, reporting the ones that failed. + /// A failure on one target never stops the rest: the user asked for all of them. + func dropContainers(_ request: DatabaseDropRequest) async { + var failures: [(name: String, message: String)] = [] + + for target in request.targets { + do { + try await dropContainer(target) + } catch { + navigationLogger.error( + "Failed to drop \(target.id, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) + failures.append((target.name, error.localizedDescription)) + } } - do { - try await driver.dropDatabase(name: name) - } catch { - navigationLogger.error("Failed to drop database: \(error.localizedDescription, privacy: .public)") - AlertHelper.showErrorSheet( - title: String(localized: "Drop Failed"), - message: error.localizedDescription, - window: contentWindow + await DatabaseTreeMetadataService.shared.refreshDatabases( + connectionId: connectionId, + databaseType: connection.type + ) + for database in Set(request.targets.filter { $0.kind == .schema }.map(\.database)) { + await DatabaseTreeMetadataService.shared.refreshSchemas( + connectionId: connectionId, + database: database ) } + + guard !failures.isEmpty else { return } + AlertHelper.showErrorSheet( + title: String(localized: "Drop Failed"), + message: dropFailureMessage(failures), + window: contentWindow + ) + } + + private func dropContainer(_ target: DatabaseContainerRef) async throws { + switch target.kind { + case .database: + guard let driver = DatabaseManager.shared.driver(for: connectionId) else { + throw DatabaseError.notConnected + } + try await driver.dropDatabase(name: target.name) + case .schema: + guard let scope = DatabaseManager.shared.resolvedScope( + database: target.database, schema: nil, for: connectionId + ) else { + throw DatabaseError.notConnected + } + let name = target.name + try await DatabaseManager.shared.withMetadataDriver(scope: scope) { driver in + try await driver.dropSchema(name: name) + } + } + } + + private func dropFailureMessage(_ failures: [(name: String, message: String)]) -> String { + failures + .map { String(format: String(localized: "%1$@: %2$@"), $0.name, $0.message) } + .joined(separator: "\n") } // MARK: - Redis Database Selection diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift index f307c6bb4..d4c222e1d 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift @@ -147,7 +147,13 @@ extension MainContentCoordinator { // MARK: - Export/Import func openExportDialog(preselectedTableNames: Set? = nil) { - exportPreselectedTableNames = preselectedTableNames + exportPreselection = preselectedTableNames.map { .tables($0) } + activeSheet = .exportDialog + } + + func openExportDialog(containers: [DatabaseContainerRef]) { + guard !containers.isEmpty else { return } + exportPreselection = .containers(containers) activeSheet = .exportDialog } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index c578f97a4..bd5c2e2c9 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -182,9 +182,9 @@ final class MainContentCoordinator { var isDatabaseSwitcherShown = false var isConnectionSwitcherShown = false var sessionContexts: [PluginSessionContext] = [] - var databaseToDrop: String? + var containerDropRequest: DatabaseDropRequest? var importFileURL: URL? - var exportPreselectedTableNames: Set? + var exportPreselection: ExportPreselection? var pendingLoadTrigger: TableLoadTrigger? @ObservationIgnored var deferredRestoreLoadTabId: UUID? diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index f92d1f2a9..355bb63a9 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -103,50 +103,35 @@ struct MainContentView: View { sheetContent(for: sheet) } .confirmationDialog( - dropConfirmationTitle, + coordinator.containerDropRequest?.title ?? "", isPresented: dropConfirmationBinding, titleVisibility: .visible, - presenting: coordinator.databaseToDrop - ) { name in - Button(String(format: String(localized: "Drop %@"), containerEntityName), role: .destructive) { - Task { await dropDatabase(name: name) } + presenting: coordinator.containerDropRequest + ) { request in + Button(request.confirmButtonTitle, role: .destructive) { + Task { await dropContainers(request) } } Button(String(localized: "Cancel"), role: .cancel) { - coordinator.databaseToDrop = nil + coordinator.containerDropRequest = nil } - } message: { _ in - Text(String(localized: "All tables and data will be permanently deleted.")) + } message: { request in + Text(request.message) } .modifier(FocusedCommandActionsModifier(actions: commandActions)) } private var dropConfirmationBinding: Binding { Binding( - get: { coordinator.databaseToDrop != nil }, + get: { coordinator.containerDropRequest != nil }, set: { newValue in - if !newValue { coordinator.databaseToDrop = nil } + if !newValue { coordinator.containerDropRequest = nil } } ) } - private var dropConfirmationTitle: String { - if let name = coordinator.databaseToDrop { - return String( - format: String(localized: "Drop %1$@ “%2$@”?"), - containerEntityName.lowercased(), - name - ) - } - return "" - } - - private var containerEntityName: String { - PluginManager.shared.containerEntityName(for: coordinator.connection.type) - } - - private func dropDatabase(name: String) async { - await coordinator.dropDatabase(name: name) - coordinator.databaseToDrop = nil + private func dropContainers(_ request: DatabaseDropRequest) async { + await coordinator.dropContainers(request) + coordinator.containerDropRequest = nil } // MARK: - Sheet Content @@ -171,7 +156,7 @@ struct MainContentView: View { set: { if !$0 { coordinator.activeSheet = nil - coordinator.exportPreselectedTableNames = nil + coordinator.exportPreselection = nil } } ) @@ -196,8 +181,8 @@ struct MainContentView: View { isPresented: dismissBinding, mode: .tables( connection: exportConnection, - preselectedTables: coordinator.exportPreselectedTableNames - ?? Set(coordinator.windowSidebarState.selectedTables.map(\.name)) + preselection: coordinator.exportPreselection + ?? .tables(Set(coordinator.windowSidebarState.selectedTables.map(\.name))) ), sidebarTables: tables ) diff --git a/TablePro/Views/Sidebar/DatabaseTreeNode.swift b/TablePro/Views/Sidebar/DatabaseTreeNode.swift index f2c4b9cbf..7094088be 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeNode.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeNode.swift @@ -49,6 +49,24 @@ final class DatabaseTreeNode { return nil } + var isContainer: Bool { + switch kind { + case .database, .schema: return true + case .recentSection, .recentTable, .table, .routine, .status: return false + } + } + + func containerRef(systemSchemas: Set) -> DatabaseContainerRef? { + switch kind { + case .database(let metadata): + return .database(metadata.name, isSystem: metadata.isSystemDatabase) + case .schema(let database, let schema): + return .schema(database: database, schema: schema, isSystem: systemSchemas.contains(schema)) + case .recentSection, .recentTable, .table, .routine, .status: + return nil + } + } + static let recentSectionId = "recent-section" static func databaseId(_ database: String) -> String { "db\u{1}\(database)" } static func schemaId(database: String, schema: String) -> String { "schema\u{1}\(database)\u{1}\(schema)" } diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift index 07c52e661..3d4de72be 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift @@ -29,6 +29,7 @@ final class DatabaseTreeOutlineCoordinator: NSObject { private var nodeCache: [String: DatabaseTreeNode] = [:] private var childrenCache: [String: [DatabaseTreeNode]] = [:] private var lastSelection: Set = [] + private var lastSelectedNodeIds: [String] = [] private var pendingSingleClickWork: DispatchWorkItem? private var isApplyingExpansion = false private var isSyncingSelection = false @@ -483,16 +484,25 @@ final class DatabaseTreeOutlineCoordinator: NSObject { // MARK: - Selection / open private func selectedRefs() -> [DatabaseTreeTableRef] { + selectedNodes().compactMap(\.tableRef) + } + + private func selectedContainerRefs() -> [DatabaseContainerRef] { + let systemSchemaNames = systemSchemas + return selectedNodes().compactMap { $0.containerRef(systemSchemas: systemSchemaNames) } + } + + private func selectedNodes() -> [DatabaseTreeNode] { guard let outlineView else { return [] } return outlineView.selectedRowIndexes.compactMap { - (outlineView.item(atRow: $0) as? DatabaseTreeNode)?.tableRef + outlineView.item(atRow: $0) as? DatabaseTreeNode } } private func syncSelectionToModel() { guard let outlineView else { return } - let rows = lastSelection.compactMap { ref -> Int? in - guard let node = nodeCache[DatabaseTreeNode.tableId(ref)] else { return nil } + let rows = lastSelectedNodeIds.compactMap { nodeId -> Int? in + guard let node = nodeCache[nodeId] else { return nil } let row = outlineView.row(forItem: node) return row >= 0 ? row : nil } @@ -558,6 +568,15 @@ final class DatabaseTreeOutlineCoordinator: NSObject { Task { await service.refreshObjects(connectionId: connectionId, database: database, schema: schema) } } + private func refreshContainers(_ targets: [DatabaseContainerRef]) { + for target in targets { + switch target.kind { + case .database: refreshDatabase(target.database) + case .schema: refreshObjects(database: target.database, schema: target.schema) + } + } + } + private func rowContext() -> DatabaseTreeRowContext { DatabaseTreeRowContext( databaseType: databaseType, @@ -581,11 +600,13 @@ final class DatabaseTreeOutlineCoordinator: NSObject { coordinator: mainCoordinator, isReadOnly: mainCoordinator?.safeModeLevel.blocksAllWrites ?? false, selectedTables: { [weak self] in Set((self?.selectedRefs() ?? []).map(\.table)) }, + selectedContainers: { [weak self] in self?.selectedContainerRefs() ?? [] }, activate: { [weak self] ref in await self?.activate(ref) }, setActiveDatabase: { [weak self] in self?.setActiveDatabase($0) }, setActiveSchema: { [weak self] database, schema in self?.setActiveSchema(database: database, schema: schema) }, - refreshDatabase: { [weak self] in self?.refreshDatabase($0) }, - refreshObjects: { [weak self] database, schema in self?.refreshObjects(database: database, schema: schema) }, + refreshContainers: { [weak self] in self?.refreshContainers($0) }, + exportContainers: { [weak self] in self?.mainCoordinator?.openExportDialog(containers: $0) }, + dropContainers: { [weak self] in self?.mainCoordinator?.requestContainerDrop($0) }, showRoutineDDL: { [weak self] routine in self?.mainCoordinator?.showRoutineDDL(routine) }, batchToggleTruncate: { [weak self] in self?.viewModel?.batchToggleTruncate(tableNames: $0) }, batchToggleDelete: { [weak self] in self?.viewModel?.batchToggleDelete(tableNames: $0) }, @@ -649,7 +670,8 @@ extension DatabaseTreeOutlineCoordinator: NSOutlineViewDelegate { } func outlineView(_ outlineView: NSOutlineView, shouldSelectItem item: Any) -> Bool { - (item as? DatabaseTreeNode)?.tableRef != nil + guard let node = item as? DatabaseTreeNode else { return false } + return node.tableRef != nil || node.isContainer } func outlineViewItemWillExpand(_ notification: Notification) { @@ -665,7 +687,9 @@ extension DatabaseTreeOutlineCoordinator: NSOutlineViewDelegate { func outlineViewSelectionDidChange(_ notification: Notification) { guard !isSyncingSelection, !isReloading else { return } - let refs = Set(selectedRefs()) + let nodes = selectedNodes() + lastSelectedNodeIds = nodes.map(\.id) + let refs = Set(nodes.compactMap(\.tableRef)) if let added = SelectionDelta.singleAddition(old: lastSelection, new: refs) { if isKeyboardDrivenSelection { pendingSingleClickWork?.cancel() diff --git a/TablePro/Views/Sidebar/DatabaseTreeRowView.swift b/TablePro/Views/Sidebar/DatabaseTreeRowView.swift index 9a9c3f816..5c0ecc7b8 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeRowView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeRowView.swift @@ -10,11 +10,13 @@ struct DatabaseTreeRowActions { let coordinator: MainContentCoordinator? let isReadOnly: Bool let selectedTables: () -> Set + let selectedContainers: () -> [DatabaseContainerRef] let activate: (DatabaseTreeTableRef) async -> Void let setActiveDatabase: (String) -> Void let setActiveSchema: (_ database: String, _ schema: String) -> Void - let refreshDatabase: (String) -> Void - let refreshObjects: (_ database: String, _ schema: String?) -> Void + let refreshContainers: ([DatabaseContainerRef]) -> Void + let exportContainers: ([DatabaseContainerRef]) -> Void + let dropContainers: ([DatabaseContainerRef]) -> Void let showRoutineDDL: (RoutineInfo) -> Void let batchToggleTruncate: ([String]) -> Void let batchToggleDelete: ([String]) -> Void @@ -188,21 +190,11 @@ struct DatabaseTreeRowView: View { actions.clearRecents() } case .database(let metadata): - Button(String(format: String(localized: "Use as Active %@"), containerEntityName)) { - actions.setActiveDatabase(metadata.name) - } - .disabled(metadata.name == context.activeDatabase) - Button(String(localized: "Refresh")) { - actions.refreshDatabase(metadata.name) - } + containerMenuItems(for: .database(metadata.name, isSystem: metadata.isSystemDatabase)) case .schema(let database, let schema): - Button(String(format: String(localized: "Use as Active %@"), schemaEntityName)) { - actions.setActiveSchema(database, schema) - } - .disabled(database == context.activeDatabase && schema == context.activeSchema) - Button(String(localized: "Refresh")) { - actions.refreshObjects(database, schema) - } + containerMenuItems( + for: .schema(database: database, schema: schema, isSystem: context.systemSchemas.contains(schema)) + ) case .table(let ref): SidebarContextMenu( clickedTable: ref.table, @@ -220,6 +212,100 @@ struct DatabaseTreeRowView: View { } } + @ViewBuilder + private func containerMenuItems(for clicked: DatabaseContainerRef) -> some View { + let targets = SidebarMenuTarget.resolveContainers(clicked: clicked, selection: actions.selectedContainers()) + let droppable = ContainerDropEligibility.droppable(targets, context: dropEligibilityContext) + + Button(useAsActiveTitle(for: clicked)) { + useAsActive(clicked) + } + .disabled(targets.count > 1 || isActive(clicked)) + + Button(String(localized: "Refresh")) { + actions.refreshContainers(targets) + } + + Button(copyNamesTitle(count: targets.count)) { + ClipboardService.shared.writeText(targets.map(\.name).joined(separator: ",")) + } + + if ExportPreselection.canPreselect(containers: targets, activeDatabase: context.activeDatabase) { + Button(String(localized: "Export…")) { + actions.exportContainers(targets) + } + } + + if !droppable.isEmpty { + Divider() + + Button(dropRequest(for: droppable).menuTitle, role: .destructive) { + actions.dropContainers(droppable) + } + } + } + + private func useAsActiveTitle(for container: DatabaseContainerRef) -> String { + String( + format: String(localized: "Use as Active %@"), + container.kind == .schema ? schemaEntityName : containerEntityName + ) + } + + private func useAsActive(_ container: DatabaseContainerRef) { + switch container.kind { + case .database: + actions.setActiveDatabase(container.database) + case .schema: + guard let schema = container.schema else { return } + actions.setActiveSchema(container.database, schema) + } + } + + private func isActive(_ container: DatabaseContainerRef) -> Bool { + switch container.kind { + case .database: + return container.database == context.activeDatabase + case .schema: + return container.database == context.activeDatabase && container.schema == context.activeSchema + } + } + + private func copyNamesTitle(count: Int) -> String { + count == 1 + ? String(localized: "Copy Name") + : String(format: String(localized: "Copy %lld Names"), count) + } + + private func dropRequest(for targets: [DatabaseContainerRef]) -> DatabaseDropRequest { + DatabaseDropRequest( + targets: targets, + entityName: entityName(for: targets), + entityNamePlural: entityNamePlural(for: targets), + dropsDependentObjects: targets.contains { $0.kind == .schema } + ) + } + + private func entityName(for targets: [DatabaseContainerRef]) -> String { + targets.contains { $0.kind == .schema } ? schemaEntityName : containerEntityName + } + + private func entityNamePlural(for targets: [DatabaseContainerRef]) -> String { + targets.contains { $0.kind == .schema } + ? PluginManager.shared.schemaEntityNamePlural(for: context.databaseType) + : PluginManager.shared.containerEntityNamePlural(for: context.databaseType) + } + + private var dropEligibilityContext: ContainerDropEligibility.Context { + ContainerDropEligibility.Context( + activeDatabase: context.activeDatabase, + activeSchema: context.activeSchema, + supportsDropDatabase: PluginManager.shared.supportsDropDatabase(for: context.databaseType), + supportsDropSchema: PluginManager.shared.supportsDropSchema(for: context.databaseType), + isReadOnly: actions.isReadOnly + ) + } + private func foreground(isActive: Bool, isSystem: Bool) -> AnyShapeStyle { if isEmphasized { return AnyShapeStyle(.white) } if isActive { return AnyShapeStyle(.tint) } diff --git a/TablePro/Views/Sidebar/SidebarContextMenu.swift b/TablePro/Views/Sidebar/SidebarContextMenu.swift index 358845b86..1129bc9ec 100644 --- a/TablePro/Views/Sidebar/SidebarContextMenu.swift +++ b/TablePro/Views/Sidebar/SidebarContextMenu.swift @@ -72,10 +72,9 @@ struct SidebarContextMenu: View { } private var effectiveTableNames: [String] { - if selectedTables.isEmpty, let table = clickedTable { - return [table.name] - } - return selectedTables.map(\.name).sorted() + SidebarMenuTarget.resolve(clicked: clickedTable, selection: Array(selectedTables)) + .map(\.name) + .sorted() } @MainActor diff --git a/TablePro/Views/Sidebar/SidebarMenuTarget.swift b/TablePro/Views/Sidebar/SidebarMenuTarget.swift new file mode 100644 index 000000000..d6fa2db6b --- /dev/null +++ b/TablePro/Views/Sidebar/SidebarMenuTarget.swift @@ -0,0 +1,24 @@ +// +// SidebarMenuTarget.swift +// TablePro +// + +import Foundation + +/// Resolves which rows a contextual menu acts on, following AppKit's convention for +/// `NSTableView.clickedRow`: a menu applies to the whole selection when the clicked row +/// is part of it, and to the clicked row alone otherwise. +enum SidebarMenuTarget { + static func resolve(clicked: Element?, selection: [Element]) -> [Element] { + guard let clicked else { return selection } + guard selection.contains(clicked) else { return [clicked] } + return selection + } + + static func resolveContainers( + clicked: DatabaseContainerRef, + selection: [DatabaseContainerRef] + ) -> [DatabaseContainerRef] { + resolve(clicked: clicked, selection: selection.matching(kind: clicked.kind)).sortedByName + } +} diff --git a/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift b/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift index 97a031a69..13becc075 100644 --- a/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift +++ b/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift @@ -35,6 +35,8 @@ final class MockDatabaseDriver: DatabaseDriver, SchemaSwitchable, @unchecked Sen var hangsUntilDisconnect = false var schemasToReturn: [String] = [] var fetchSchemasError: Error? + var databasesToReturn: [String] = [] + var fetchDatabasesError: Error? private var hangContinuation: CheckedContinuation? init(connection: DatabaseConnection = TestFixtures.makeConnection()) { @@ -116,7 +118,12 @@ final class MockDatabaseDriver: DatabaseDriver, SchemaSwitchable, @unchecked Sen ) } - func fetchDatabases() async throws -> [String] { [] } + func fetchDatabases() async throws -> [String] { + if let fetchDatabasesError { + throw fetchDatabasesError + } + return databasesToReturn + } func fetchDatabaseMetadata(_ database: String) async throws -> DatabaseMetadata { DatabaseMetadata( id: database, name: database, tableCount: nil, sizeBytes: nil, diff --git a/TableProTests/Core/Services/Query/DatabaseTreeMetadataServiceTests.swift b/TableProTests/Core/Services/Query/DatabaseTreeMetadataServiceTests.swift index cbbb8e35a..cd61ebb36 100644 --- a/TableProTests/Core/Services/Query/DatabaseTreeMetadataServiceTests.swift +++ b/TableProTests/Core/Services/Query/DatabaseTreeMetadataServiceTests.swift @@ -136,3 +136,56 @@ struct DatabaseTreeMetadataServiceRefreshTests { #expect(tables.isEmpty) } } + +/// A refresh must never empty the list it is refreshing: the tree renders `.loading` +/// with no content as a spinner, so clearing first blanks the sidebar mid-refresh. +@Suite("DatabaseTreeMetadataService refreshDatabases") +@MainActor +struct DatabaseTreeMetadataServiceRefreshDatabasesTests { + @Test("A refresh commits the new list over the old one") + func refreshCommitsNewList() async { + let connection = TestFixtures.makeConnection(type: .pglite) + let driver = MockDatabaseDriver(connection: connection) + driver.databasesToReturn = ["sales", "analytics"] + + var session = ConnectionSession(connection: connection, driver: driver) + session.status = .connected + DatabaseManager.shared.injectSession(session, for: connection.id) + let service = DatabaseTreeMetadataService.shared + + await service.loadDatabases(connectionId: connection.id, databaseType: connection.type) + #expect(service.databases(for: connection.id).map(\.name) == ["analytics", "sales"]) + + driver.databasesToReturn = ["sales"] + await service.refreshDatabases(connectionId: connection.id, databaseType: connection.type) + + #expect(service.databases(for: connection.id).map(\.name) == ["sales"]) + + await service.handleDisconnect(connectionId: connection.id) + DatabaseManager.shared.removeSession(for: connection.id) + } + + @Test("A failed refresh keeps the databases already on screen") + func failedRefreshKeepsPreviousList() async { + let connection = TestFixtures.makeConnection(type: .pglite) + let driver = MockDatabaseDriver(connection: connection) + driver.databasesToReturn = ["sales", "analytics"] + + var session = ConnectionSession(connection: connection, driver: driver) + session.status = .connected + DatabaseManager.shared.injectSession(session, for: connection.id) + let service = DatabaseTreeMetadataService.shared + + await service.loadDatabases(connectionId: connection.id, databaseType: connection.type) + driver.fetchDatabasesError = DatabaseError.notConnected + await service.refreshDatabases(connectionId: connection.id, databaseType: connection.type) + + #expect(service.databases(for: connection.id).map(\.name) == ["analytics", "sales"]) + if case .loaded = service.databaseListState(for: connection.id) {} else { + Issue.record("A failed refresh must leave the list loaded, not failed or loading") + } + + await service.handleDisconnect(connectionId: connection.id) + DatabaseManager.shared.removeSession(for: connection.id) + } +} diff --git a/TableProTests/Models/Database/ContainerDropEligibilityTests.swift b/TableProTests/Models/Database/ContainerDropEligibilityTests.swift new file mode 100644 index 000000000..847c14343 --- /dev/null +++ b/TableProTests/Models/Database/ContainerDropEligibilityTests.swift @@ -0,0 +1,103 @@ +// +// ContainerDropEligibilityTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("Container Drop Eligibility") +struct ContainerDropEligibilityTests { + private func context( + activeDatabase: String? = "sales", + activeSchema: String? = "public", + supportsDropDatabase: Bool = true, + supportsDropSchema: Bool = true, + isReadOnly: Bool = false + ) -> ContainerDropEligibility.Context { + ContainerDropEligibility.Context( + activeDatabase: activeDatabase, + activeSchema: activeSchema, + supportsDropDatabase: supportsDropDatabase, + supportsDropSchema: supportsDropSchema, + isReadOnly: isReadOnly + ) + } + + @Test("System databases are never droppable") + func systemDatabaseExcluded() { + let targets: [DatabaseContainerRef] = [ + .database("mysql", isSystem: true), + .database("analytics") + ] + + let droppable = ContainerDropEligibility.droppable(targets, context: context()) + + #expect(droppable.map(\.name) == ["analytics"]) + } + + @Test("The active database is not droppable") + func activeDatabaseExcluded() { + let targets: [DatabaseContainerRef] = [.database("sales"), .database("analytics")] + + let droppable = ContainerDropEligibility.droppable(targets, context: context()) + + #expect(droppable.map(\.name) == ["analytics"]) + } + + @Test("The active schema of the active database is not droppable") + func activeSchemaExcluded() { + let targets: [DatabaseContainerRef] = [ + .schema(database: "sales", schema: "public"), + .schema(database: "sales", schema: "reporting") + ] + + let droppable = ContainerDropEligibility.droppable(targets, context: context()) + + #expect(droppable.map(\.name) == ["reporting"]) + } + + @Test("A schema named like the active one in another database stays droppable") + func sameSchemaNameInOtherDatabaseAllowed() { + let targets: [DatabaseContainerRef] = [.schema(database: "analytics", schema: "public")] + + let droppable = ContainerDropEligibility.droppable(targets, context: context()) + + #expect(droppable.count == 1) + } + + @Test("System schemas are never droppable") + func systemSchemaExcluded() { + let targets: [DatabaseContainerRef] = [ + .schema(database: "sales", schema: "pg_catalog", isSystem: true) + ] + + #expect(ContainerDropEligibility.droppable(targets, context: context()).isEmpty) + } + + @Test("Drivers without the capability drop nothing") + func capabilityGatesEachKind() { + let databases: [DatabaseContainerRef] = [.database("analytics")] + let schemas: [DatabaseContainerRef] = [.schema(database: "analytics", schema: "reporting")] + + #expect( + ContainerDropEligibility.droppable( + databases, context: context(supportsDropDatabase: false) + ).isEmpty + ) + #expect( + ContainerDropEligibility.droppable( + schemas, context: context(supportsDropSchema: false) + ).isEmpty + ) + } + + @Test("Read-only connections drop nothing") + func readOnlyDropsNothing() { + let targets: [DatabaseContainerRef] = [.database("analytics")] + + #expect(ContainerDropEligibility.droppable(targets, context: context(isReadOnly: true)).isEmpty) + } +} diff --git a/TableProTests/Models/Database/DatabaseDropRequestTests.swift b/TableProTests/Models/Database/DatabaseDropRequestTests.swift new file mode 100644 index 000000000..37ff1d434 --- /dev/null +++ b/TableProTests/Models/Database/DatabaseDropRequestTests.swift @@ -0,0 +1,93 @@ +// +// DatabaseDropRequestTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("Database Drop Request") +struct DatabaseDropRequestTests { + private func request( + _ targets: [DatabaseContainerRef], + entityName: String = "Database", + entityNamePlural: String = "Databases", + dropsDependentObjects: Bool = false + ) -> DatabaseDropRequest { + DatabaseDropRequest( + targets: targets, + entityName: entityName, + entityNamePlural: entityNamePlural, + dropsDependentObjects: dropsDependentObjects + ) + } + + @Test("A single target names the container in the title") + func singleTargetTitle() { + let dropRequest = request([.database("sales")]) + + #expect(dropRequest.title.contains("sales")) + #expect(dropRequest.title.contains("database")) + #expect(!dropRequest.message.contains("sales")) + } + + @Test("Several targets count in the title and list every name") + func multipleTargetTitleAndMessage() { + let dropRequest = request([.database("sales"), .database("analytics"), .database("archive")]) + + #expect(dropRequest.title.contains("3")) + #expect(dropRequest.title.contains("databases")) + for name in ["sales", "analytics", "archive"] { + #expect(dropRequest.message.contains(name)) + } + } + + @Test("Targets are sorted by name") + func targetsSorted() { + let dropRequest = request([.database("sales"), .database("analytics")]) + + #expect(dropRequest.names == ["analytics", "sales"]) + } + + @Test("A long list is capped and reports the overflow") + func longListIsCapped() { + let targets = (1...14).map { DatabaseContainerRef.database("db\($0)") } + + let dropRequest = request(targets) + + #expect(dropRequest.message.contains("and 4 more")) + #expect(dropRequest.message.contains("db10")) + #expect(!dropRequest.message.contains("db11")) + } + + @Test("Schema drops warn about dependent objects") + func schemaDropWarnsAboutDependents() { + let dropRequest = request( + [.schema(database: "sales", schema: "reporting")], + entityName: "Schema", + entityNamePlural: "Schemas", + dropsDependentObjects: true + ) + + #expect(dropRequest.kind == .schema) + #expect(dropRequest.message.contains("depend")) + } + + @Test("The menu title ends with an ellipsis because it opens a confirmation") + func menuTitleHasEllipsis() { + #expect(request([.database("sales")]).menuTitle.hasSuffix("…")) + #expect(request([.database("a"), .database("b")]).menuTitle.hasSuffix("…")) + } + + @Test("Identity follows the target set") + func identityFollowsTargets() { + let first = request([.database("a"), .database("b")]) + let second = request([.database("b"), .database("a")]) + let third = request([.database("a")]) + + #expect(first.id == second.id) + #expect(first.id != third.id) + } +} diff --git a/TableProTests/Models/Export/ExportPreselectionTests.swift b/TableProTests/Models/Export/ExportPreselectionTests.swift new file mode 100644 index 000000000..d0eabf0a5 --- /dev/null +++ b/TableProTests/Models/Export/ExportPreselectionTests.swift @@ -0,0 +1,72 @@ +// +// ExportPreselectionTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("Export Preselection") +struct ExportPreselectionTests { + @Test("Named tables only select inside the current container") + func namedTablesStayInCurrentContainer() { + let preselection = ExportPreselection.tables(["users"]) + + #expect(preselection.selects(table: "users", inContainer: "sales", isCurrentContainer: true)) + #expect(!preselection.selects(table: "users", inContainer: "analytics", isCurrentContainer: false)) + } + + @Test("A container preselection selects every table it holds") + func containerSelectsAllItsTables() { + let preselection = ExportPreselection.containers([.database("analytics")]) + + #expect(preselection.selects(table: "events", inContainer: "analytics", isCurrentContainer: false)) + #expect(preselection.selects(table: "sessions", inContainer: "analytics", isCurrentContainer: false)) + } + + @Test("A container preselection ignores tables in other containers") + func containerIgnoresOtherContainers() { + let preselection = ExportPreselection.containers([.database("analytics")]) + + #expect(!preselection.selects(table: "events", inContainer: "sales", isCurrentContainer: true)) + } + + @Test("Schema containers match by schema name") + func schemaContainersMatchByName() { + let preselection = ExportPreselection.containers([.schema(database: "sales", schema: "reporting")]) + + #expect(preselection.selects(table: "totals", inContainer: "reporting", isCurrentContainer: false)) + #expect(!preselection.selects(table: "totals", inContainer: "public", isCurrentContainer: true)) + } + + @Test("A single table names the export file") + func singleTableNamesTheFile() { + #expect(ExportPreselection.tables(["users"]).singleTableName == "users") + #expect(ExportPreselection.tables(["users", "orders"]).singleTableName == nil) + #expect(ExportPreselection.containers([.database("sales")]).singleTableName == nil) + } + + @Test("Databases can always be preselected, schemas only in the connected database") + func canPreselectRules() { + #expect(ExportPreselection.canPreselect( + containers: [.database("analytics")], activeDatabase: "sales" + )) + #expect(ExportPreselection.canPreselect( + containers: [.schema(database: "sales", schema: "reporting")], activeDatabase: "sales" + )) + #expect(!ExportPreselection.canPreselect( + containers: [.schema(database: "analytics", schema: "reporting")], activeDatabase: "sales" + )) + #expect(!ExportPreselection.canPreselect(containers: [], activeDatabase: "sales")) + } + + @Test("Container names are exposed for expansion and file naming") + func containerNamesExposed() { + let preselection = ExportPreselection.containers([.database("sales"), .database("analytics")]) + + #expect(preselection.containerNames == ["sales", "analytics"]) + #expect(ExportPreselection.tables(["users"]).containerNames.isEmpty) + } +} diff --git a/TableProTests/Views/Sidebar/SidebarMenuTargetTests.swift b/TableProTests/Views/Sidebar/SidebarMenuTargetTests.swift new file mode 100644 index 000000000..c6a1b5ee7 --- /dev/null +++ b/TableProTests/Views/Sidebar/SidebarMenuTargetTests.swift @@ -0,0 +1,70 @@ +// +// SidebarMenuTargetTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("Sidebar Menu Target") +struct SidebarMenuTargetTests { + @Test("Clicking inside the selection acts on the whole selection") + func clickInsideSelectionActsOnSelection() { + let target = SidebarMenuTarget.resolve(clicked: "b", selection: ["a", "b", "c"]) + #expect(target == ["a", "b", "c"]) + } + + @Test("Clicking outside the selection acts on the clicked row only") + func clickOutsideSelectionActsOnClickedRow() { + let target = SidebarMenuTarget.resolve(clicked: "d", selection: ["a", "b", "c"]) + #expect(target == ["d"]) + } + + @Test("An empty selection acts on the clicked row") + func emptySelectionActsOnClickedRow() { + let target = SidebarMenuTarget.resolve(clicked: "a", selection: [String]()) + #expect(target == ["a"]) + } + + @Test("No clicked row falls back to the selection") + func noClickedRowUsesSelection() { + let target = SidebarMenuTarget.resolve(clicked: String?.none, selection: ["a", "b"]) + #expect(target == ["a", "b"]) + } + + @Test("A mixed selection is filtered to the clicked row's kind") + func mixedSelectionFiltersByKind() { + let clicked = DatabaseContainerRef.database("sales") + let selection: [DatabaseContainerRef] = [ + .database("sales"), + .database("analytics"), + .schema(database: "sales", schema: "public") + ] + + let target = SidebarMenuTarget.resolveContainers(clicked: clicked, selection: selection) + + #expect(target.map(\.name) == ["analytics", "sales"]) + #expect(target.allSatisfy { $0.kind == .database }) + } + + @Test("Clicking a schema outside the selection drops the selected databases") + func schemaClickOutsideSelectionActsAlone() { + let clicked = DatabaseContainerRef.schema(database: "sales", schema: "reporting") + let selection: [DatabaseContainerRef] = [.database("sales"), .database("analytics")] + + let target = SidebarMenuTarget.resolveContainers(clicked: clicked, selection: selection) + + #expect(target == [clicked]) + } + + @Test("The same schema name under two databases stays distinct") + func sameSchemaNameDifferentDatabaseIsDistinct() { + let first = DatabaseContainerRef.schema(database: "db1", schema: "public") + let second = DatabaseContainerRef.schema(database: "db2", schema: "public") + + #expect(first != second) + #expect(Set([first, second]).count == 2) + } +} diff --git a/docs/features/table-operations.mdx b/docs/features/table-operations.mdx index 7f60a5620..caba3c64c 100644 --- a/docs/features/table-operations.mdx +++ b/docs/features/table-operations.mdx @@ -81,8 +81,31 @@ Click **New Database...** at the bottom of the switcher. The form fields come fr ### Drop Database -Right-click a database in the switcher and choose **Drop Database...**. The item is hidden for system databases and for the database you are currently connected to; switch to another one first. A confirmation dialog shows the database name in its title with a red **Drop Database** button. +Right-click a database in the switcher or in the sidebar tree and choose **Drop Database...**. The item is hidden for system databases and for the database you are currently connected to; switch to another one first. A confirmation dialog shows the database name in its title with a red **Drop Database** button. Dropping a database permanently deletes all its tables and data. The server rejects the drop if your account lacks the required privilege. + +### Work on Several Databases at Once + +Select more than one database in the sidebar tree or in the switcher: Shift-click for a range, Cmd-click to add or remove one. The right-click menu then acts on the whole selection and says how many it covers, for example **Drop 3 Databases...**. + +| Action | What it does | +|--------|--------------| +| Refresh | Reloads the object list for every selected database. | +| Copy Names | Copies the selected names to the clipboard, comma-separated. | +| Export... | Opens the export dialog with every table of the selected databases already ticked. | +| Drop | Drops all of them after one confirmation that lists every name. | + +The menu follows the row you right-click: if it is part of the selection, the action covers the selection; if it is not, the action covers just that row. **Use as Active Database** stays single-target and is disabled while more than one row is selected. + +Databases the server will not let you drop are left out of the Drop item: system databases and the one you are connected to. If a drop fails partway, TablePro finishes the rest and then reports which ones failed and why. + +### Drop Schema + +On engines that group tables by schema, the same menu appears on a schema row. PostgreSQL, SQL Server and SurrealDB support it. PostgreSQL drops the schema with `CASCADE`, so objects that depend on it go too. SQL Server has no cascade, so it refuses to drop a schema that still holds objects; drop those first. + + +Dropping a schema permanently deletes everything in it. On PostgreSQL it also drops objects in other schemas that depend on it. + From e1feaeaea0872c50591bde63bb058acaccc5cbec Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 12 Aug 2026 11:15:10 +0700 Subject: [PATCH 2/3] docs(databases): document drop schema on PostgreSQL, SQL Server and SurrealDB --- docs/databases/mssql.mdx | 4 ++++ docs/databases/postgresql.mdx | 2 +- docs/databases/surrealdb.mdx | 2 ++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/databases/mssql.mdx b/docs/databases/mssql.mdx index 8155fbdd6..72a30b228 100644 --- a/docs/databases/mssql.mdx +++ b/docs/databases/mssql.mdx @@ -85,6 +85,10 @@ See [Connection URL Reference](/databases/connection-urls) for all parameters. The sidebar nests tables under their schema and hides the built-in role schemas (`db_owner`, `guest`, and the rest). Switch the active database with **Cmd+K**; switches happen in place, no reconnect. Click a schema in the sidebar to make it active, or set a starting schema in the connection's **Schema** field. Opening a table always queries it in the schema it is listed under, so tables outside `dbo` work without switching first. `master`, `tempdb`, `model`, and `msdb` are marked as system databases. +Select more than one database or schema to act on them together: Shift-click for a range, Cmd-click to add or remove one. The right-click menu then covers the whole selection. See [Work on Several Databases at Once](/features/table-operations#work-on-several-databases-at-once). + +**Drop Schema**: right-click a schema and choose **Drop Schema...**. T-SQL has no cascading drop, so SQL Server refuses to drop a schema that still owns objects; drop or move those first. The active schema and the built-in role schemas are left out. + ## Features - **Table structure**: columns, indexes, foreign keys, triggers, and generated CREATE TABLE DDL. diff --git a/docs/databases/postgresql.mdx b/docs/databases/postgresql.mdx index 41d9a1b3c..7f1b70cf1 100644 --- a/docs/databases/postgresql.mdx +++ b/docs/databases/postgresql.mdx @@ -35,7 +35,7 @@ Connect to RDS or Aurora with your AWS identity instead of a static password: se ## Features -**Schemas**: The sidebar shows all accessible schemas and tables. Cmd+K switches databases; pick the active schema from the schema menu at the bottom of the sidebar. Table info shows columns, indexes, constraints, and DDL. +**Schemas**: The sidebar shows all accessible schemas and tables. Cmd+K switches databases; pick the active schema from the schema menu at the bottom of the sidebar. Table info shows columns, indexes, constraints, and DDL. Right-click a schema for **Drop Schema...**, which runs `DROP SCHEMA ... CASCADE`, so objects in other schemas that depend on it are dropped too. Select several databases or schemas with Shift-click or Cmd-click and the menu covers all of them. See [Work on Several Databases at Once](/features/table-operations#work-on-several-databases-at-once). **Databases**: Every database on the server is listed, including `postgres`. It is an ordinary database that `initdb` creates for users and applications, not a system database. `template0` and `template1` are not listed. diff --git a/docs/databases/surrealdb.mdx b/docs/databases/surrealdb.mdx index 59cbbdf10..334e3119f 100644 --- a/docs/databases/surrealdb.mdx +++ b/docs/databases/surrealdb.mdx @@ -21,6 +21,8 @@ SurrealDB nests tables under a namespace and a database. TablePro maps that stra Set the namespace in the **Namespace** field when you create the connection. Leave **Database** empty to pick one from the sidebar after connecting. +Right-click a namespace for **Drop Namespace...** (`REMOVE NAMESPACE`) or a database for **Drop Database...** (`REMOVE DATABASE`). Select several first with Shift-click or Cmd-click and the menu covers all of them, with one confirmation listing every name. See [Work on Several Databases at Once](/features/table-operations#work-on-several-databases-at-once). + ## Authentication SurrealDB cannot infer which level your credentials belong to, so pick the one that matches the user you are signing in as. From 1cc88e00799728f5715654dca1426863d5125313 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 12 Aug 2026 11:28:17 +0700 Subject: [PATCH 3/3] fix(hig): keep database switcher row icons visible on a selected row --- CHANGELOG.md | 1 + TablePro/Resources/Localizable.xcstrings | 62 ++++++++++++++++--- .../DatabaseSwitcherPopover.swift | 4 +- .../Views/Shared/SelectionAwareTint.swift | 34 ++++++++++ TablePro/Views/Sidebar/FavoritesTabView.swift | 2 +- TablePro/Views/Sidebar/RoutineRowView.swift | 2 +- TablePro/Views/Sidebar/SidebarTint.swift | 21 ------- TablePro/Views/Sidebar/TableRowView.swift | 2 +- .../Shared/SelectionAwareTintTests.swift | 32 ++++++++++ 9 files changed, 127 insertions(+), 33 deletions(-) create mode 100644 TablePro/Views/Shared/SelectionAwareTint.swift delete mode 100644 TablePro/Views/Sidebar/SidebarTint.swift create mode 100644 TableProTests/Views/Shared/SelectionAwareTintTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index fd3920842..792018fa9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Right-clicking a table that is not part of the current selection now acts on that table. It used to act on the selected tables instead, including for Delete. +- Database icons and the current-database checkmark stay visible on a selected row in the database switcher. They were drawn in the accent colour, which vanished against the accent-coloured selection. - The sidebar drops a database from the tree as soon as you drop it on the server, instead of listing it until you reconnect. - Refreshing the database list keeps the current list on screen while it reloads, and keeps it if the reload fails. - MQL export writes a MongoDB `_id` as `ObjectId("...")` and a date as `ISODate("...")`, so running the script inserts the same types back instead of strings. A value nested inside a subdocument is still exported as a string. diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index a3bdb057c..0a512bf41 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -140,12 +140,6 @@ } } } - }, - "Do Not Decode" : { - - }, - "Legacy UUID Encoding" : { - }, "—" : { "extractionState" : "stale", @@ -176,6 +170,10 @@ } } }, + ", and %lld more" : { + "comment" : "Plural suffix for the number of overflow items.", + "isCommentAutoGenerated" : true + }, ":" : { "localizations" : { "tr" : { @@ -2639,6 +2637,10 @@ "%1$@, %2$@" : { "shouldTranslate" : false }, + "%1$@: %2$@" : { + "comment" : "A list of failed drops, one per line.", + "isCommentAutoGenerated" : true + }, "%1$lld of %2$lld statements were applied. This connection does not roll back user and role changes." : { "localizations" : { "tr" : { @@ -22855,6 +22857,10 @@ } } }, + "Copy %lld Names" : { + "comment" : "A button that copies the names of the selected containers to the clipboard.", + "isCommentAutoGenerated" : true + }, "Copy All" : { "localizations" : { "tr" : { @@ -30939,6 +30945,9 @@ } } } + }, + "Do Not Decode" : { + }, "Do you want to save changes?" : { "localizations" : { @@ -31360,6 +31369,10 @@ } } }, + "Driver Options" : { + "comment" : "A section for driver-specific options.", + "isCommentAutoGenerated" : true + }, "Driver plugin not loaded. Open Settings to update." : { "localizations" : { "tr" : { @@ -31445,6 +31458,7 @@ } }, "Drop %@…" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -31500,6 +31514,22 @@ } } }, + "Drop %1$@ “%2$@”…" : { + "comment" : "The text of the menu item for dropping a single database. The argument is the name of the database.", + "isCommentAutoGenerated" : true + }, + "Drop %1$lld %2$@" : { + "comment" : "The text of the button that confirms the user's intention to drop multiple objects. The first argument is the pluralized name of the object. The second argument is the number of objects to be dropped.", + "isCommentAutoGenerated" : true + }, + "Drop %1$lld %2$@?" : { + "comment" : "The title of a confirmation alert when dropping multiple databases. The first argument is the number of databases being dropped. The second argument is the pluralized name of the database type.", + "isCommentAutoGenerated" : true + }, + "Drop %1$lld %2$@…" : { + "comment" : "Text for the \"Drop\" menu item.", + "isCommentAutoGenerated" : true + }, "Drop %d tables" : { "localizations" : { "tr" : { @@ -37328,7 +37358,6 @@ } }, "Export…" : { - "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -49415,6 +49444,9 @@ } } } + }, + "Legacy UUID Encoding" : { + }, "Length" : { "extractionState" : "stale", @@ -60614,6 +60646,10 @@ } } }, + "Objects that depend on them will be dropped too." : { + "comment" : "Message in a confirmation alert when the user has selected to drop all objects that depend on the dropped objects.", + "isCommentAutoGenerated" : true + }, "Off" : { "localizations" : { "tr" : { @@ -84896,6 +84932,10 @@ } } }, + "Startup SQL" : { + "comment" : "A section header for the startup SQL commands of a connection.", + "isCommentAutoGenerated" : true + }, "State" : { "localizations" : { "tr" : { @@ -91236,6 +91276,10 @@ } } }, + "This connection runs SQL every time it connects, using your credentials." : { + "comment" : "A description of the startup SQL for a database connection.", + "isCommentAutoGenerated" : true + }, "This connection was deleted on another device or window. Your changes were not saved." : { "localizations" : { "tr" : { @@ -100283,6 +100327,10 @@ } } }, + "Your connections file was changed outside TablePro, so this connection's password source was not run. Open the connection and save it again to confirm the change." : { + "comment" : "Error message when the app is not trusted to access the keychain.", + "isCommentAutoGenerated" : true + }, "Your database schema and query data will be sent to the AI provider for analysis. Allow for this connection?" : { "localizations" : { "tr" : { diff --git a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift index 817e4be0e..f5e2af4d4 100644 --- a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift +++ b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift @@ -182,13 +182,13 @@ struct DatabaseSwitcherPopover: View { return HStack(spacing: 8) { Image(systemName: "checkmark") .font(.body.weight(.semibold)) - .foregroundStyle(Color.accentColor) + .selectionAwareTint(Color.accentColor) .opacity(isCurrent ? 1 : 0) .frame(width: 14) Image(systemName: database.icon) .font(.body) - .foregroundStyle(database.isSystemDatabase ? Color.secondary : Color.accentColor) + .selectionAwareTint(database.isSystemDatabase ? Color.secondary : Color.accentColor) .frame(width: 16) Text(database.name) diff --git a/TablePro/Views/Shared/SelectionAwareTint.swift b/TablePro/Views/Shared/SelectionAwareTint.swift new file mode 100644 index 000000000..fbeba9ad2 --- /dev/null +++ b/TablePro/Views/Shared/SelectionAwareTint.swift @@ -0,0 +1,34 @@ +// +// SelectionAwareTint.swift +// TablePro +// + +import SwiftUI + +/// Content drawn on a prominent selection fill has to switch to the selected-content +/// colour, the way `NSColor.alternateSelectedControlTextColor` does in AppKit. A tint +/// left at the accent colour renders accent-on-accent and disappears. +enum SelectionAwareTintResolver { + static func color(standard: Color, prominence: BackgroundProminence) -> Color { + prominence == .increased ? .white : standard + } +} + +private struct SelectionAwareTint: ViewModifier { + let standard: Color + @Environment(\.backgroundProminence) private var backgroundProminence + + func body(content: Content) -> some View { + content.foregroundStyle( + SelectionAwareTintResolver.color(standard: standard, prominence: backgroundProminence) + ) + } +} + +extension View { + /// Tints content with `color`, switching to the selected-content colour when the view + /// sits on a prominent selection background. + func selectionAwareTint(_ color: Color) -> some View { + modifier(SelectionAwareTint(standard: color)) + } +} diff --git a/TablePro/Views/Sidebar/FavoritesTabView.swift b/TablePro/Views/Sidebar/FavoritesTabView.swift index d82f1139b..687a7d321 100644 --- a/TablePro/Views/Sidebar/FavoritesTabView.swift +++ b/TablePro/Views/Sidebar/FavoritesTabView.swift @@ -293,7 +293,7 @@ internal struct FavoritesTabView: View { Text(table.name) } icon: { Image(systemName: TableRowLogic.iconName(for: table.type)) - .sidebarTint(Color.accentColor) + .selectionAwareTint(Color.accentColor) } .sidebarRowIcon(visible: AppSettingsManager.shared.general.showObjectIcons) .tag(FavoriteSelection.table(database: activeDatabase, schema: table.schema, name: table.name)) diff --git a/TablePro/Views/Sidebar/RoutineRowView.swift b/TablePro/Views/Sidebar/RoutineRowView.swift index 9eacae7c2..4d62b18ca 100644 --- a/TablePro/Views/Sidebar/RoutineRowView.swift +++ b/TablePro/Views/Sidebar/RoutineRowView.swift @@ -40,7 +40,7 @@ struct RoutineRowView: View { .truncationMode(.tail) } icon: { Image(systemName: RoutineRowLogic.iconName(for: routine.kind)) - .sidebarTint(Color.accentColor) + .selectionAwareTint(Color.accentColor) .frame(width: 16) } .sidebarRowIcon(visible: AppSettingsManager.shared.general.showObjectIcons) diff --git a/TablePro/Views/Sidebar/SidebarTint.swift b/TablePro/Views/Sidebar/SidebarTint.swift deleted file mode 100644 index 12664b9a2..000000000 --- a/TablePro/Views/Sidebar/SidebarTint.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// SidebarTint.swift -// TablePro -// - -import SwiftUI - -private struct SidebarTint: ViewModifier { - let color: Color - @Environment(\.backgroundProminence) private var backgroundProminence - - func body(content: Content) -> some View { - content.foregroundStyle(backgroundProminence == .increased ? Color.white : color) - } -} - -extension View { - func sidebarTint(_ color: Color) -> some View { - modifier(SidebarTint(color: color)) - } -} diff --git a/TablePro/Views/Sidebar/TableRowView.swift b/TablePro/Views/Sidebar/TableRowView.swift index a9d129945..2e88e3444 100644 --- a/TablePro/Views/Sidebar/TableRowView.swift +++ b/TablePro/Views/Sidebar/TableRowView.swift @@ -108,7 +108,7 @@ struct TableRow: View { } icon: { if showsObjectIcon { Image(systemName: TableRowLogic.iconName(for: table.type)) - .sidebarTint(Color.accentColor) + .selectionAwareTint(Color.accentColor) .frame(width: 16) .overlay(alignment: .bottomTrailing) { pendingStateBadge diff --git a/TableProTests/Views/Shared/SelectionAwareTintTests.swift b/TableProTests/Views/Shared/SelectionAwareTintTests.swift new file mode 100644 index 000000000..04445290f --- /dev/null +++ b/TableProTests/Views/Shared/SelectionAwareTintTests.swift @@ -0,0 +1,32 @@ +// +// SelectionAwareTintTests.swift +// TableProTests +// + +import SwiftUI +import Testing + +@testable import TablePro + +@Suite("Selection Aware Tint") +struct SelectionAwareTintTests { + @Test("A prominent selection background takes the selected-content colour") + func prominentBackgroundUsesSelectedContentColor() { + let resolved = SelectionAwareTintResolver.color(standard: .accentColor, prominence: .increased) + + #expect(resolved == .white) + } + + @Test("A standard background keeps the tint") + func standardBackgroundKeepsTint() { + let resolved = SelectionAwareTintResolver.color(standard: .accentColor, prominence: .standard) + + #expect(resolved == .accentColor) + } + + @Test("A secondary tint follows the same rule, so it never sits grey on the fill") + func secondaryTintAlsoFlips() { + #expect(SelectionAwareTintResolver.color(standard: .secondary, prominence: .increased) == .white) + #expect(SelectionAwareTintResolver.color(standard: .secondary, prominence: .standard) == .secondary) + } +}