diff --git a/CHANGELOG.md b/CHANGELOG.md index a03713b8a..6cd67b2ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- BigQuery datasets can be switched from the toolbar, the Cmd+K switcher, and the File menu, including creating and dropping datasets. (#509) + +### Changed + +- Switcher, menus, and alerts now use each database's own container name: Dataset for BigQuery, Keyspace for Cassandra and ScyllaDB. (#509) + ### Fixed - iCloud Sync between the iPhone and Mac apps: the iOS app now uses the Production CloudKit environment, so a development build no longer syncs into a separate database the Mac never reads. diff --git a/Plugins/BigQueryDriverPlugin/BigQueryPlugin.swift b/Plugins/BigQueryDriverPlugin/BigQueryPlugin.swift index 05422ad54..aaf84b06e 100644 --- a/Plugins/BigQueryDriverPlugin/BigQueryPlugin.swift +++ b/Plugins/BigQueryDriverPlugin/BigQueryPlugin.swift @@ -41,6 +41,7 @@ final class BigQueryPlugin: NSObject, TableProPlugin, DriverPlugin { static let supportsSSH = false static let supportsSSL = false static let tableEntityName = "Tables" + static let containerEntityName = "Dataset" static let supportsForeignKeyDisable = false static let supportsReadOnlyMode = true static let databaseGroupingStrategy: GroupingStrategy = .hierarchicalSchema diff --git a/Plugins/CassandraDriverPlugin/CassandraPlugin.swift b/Plugins/CassandraDriverPlugin/CassandraPlugin.swift index 3ad9a45f8..0113b6e55 100644 --- a/Plugins/CassandraDriverPlugin/CassandraPlugin.swift +++ b/Plugins/CassandraDriverPlugin/CassandraPlugin.swift @@ -24,7 +24,7 @@ internal final class CassandraPlugin: NSObject, TableProPlugin, DriverPlugin { static let databaseTypeId = "Cassandra" static let databaseDisplayName = "Cassandra / ScyllaDB" static let iconName = "cassandra-icon" - static let defaultPort = 9042 + static let defaultPort = 9_042 static let additionalConnectionFields: [ConnectionField] = AWSAuthFields.standard() static let additionalDatabaseTypeIds: [String] = ["ScyllaDB"] @@ -36,6 +36,7 @@ internal final class CassandraPlugin: NSObject, TableProPlugin, DriverPlugin { static let brandColorHex = "#26A0D8" static let queryLanguageName = "CQL" static let supportsDatabaseSwitching = true + static let containerEntityName = "Keyspace" static let databaseGroupingStrategy: GroupingStrategy = .byDatabase static let defaultGroupName = "default" static let systemDatabaseNames: [String] = [ @@ -603,4 +604,3 @@ internal final class CassandraPluginDriver: PluginDatabaseDriver, @unchecked Sen } } } - diff --git a/Plugins/TableProPluginKit/DriverPlugin.swift b/Plugins/TableProPluginKit/DriverPlugin.swift index 4bfee43bf..9a3f80f07 100644 --- a/Plugins/TableProPluginKit/DriverPlugin.swift +++ b/Plugins/TableProPluginKit/DriverPlugin.swift @@ -37,6 +37,7 @@ public protocol DriverPlugin: TableProPlugin { static var sqlDialect: SQLDialectDescriptor? { get } static var statementCompletions: [CompletionEntry] { get } static var tableEntityName: String { get } + static var containerEntityName: String { get } static var supportsCascadeDrop: Bool { get } static var supportsForeignKeyDisable: Bool { get } static var immutableColumns: [String] { get } @@ -105,6 +106,7 @@ public extension DriverPlugin { static var sqlDialect: SQLDialectDescriptor? { nil } static var statementCompletions: [CompletionEntry] { [] } static var tableEntityName: String { "Tables" } + static var containerEntityName: String { "Database" } static var supportsCascadeDrop: Bool { false } static var supportsForeignKeyDisable: Bool { true } static var immutableColumns: [String] { [] } diff --git a/TablePro/Core/Plugins/ContainerSwitchTarget.swift b/TablePro/Core/Plugins/ContainerSwitchTarget.swift new file mode 100644 index 000000000..6571bb9bf --- /dev/null +++ b/TablePro/Core/Plugins/ContainerSwitchTarget.swift @@ -0,0 +1,9 @@ +// +// ContainerSwitchTarget.swift +// TablePro +// + +enum ContainerSwitchTarget: Sendable { + case database + case schema +} diff --git a/TablePro/Core/Plugins/PluginManager+Registration.swift b/TablePro/Core/Plugins/PluginManager+Registration.swift index 6dd4c1039..aafb39862 100644 --- a/TablePro/Core/Plugins/PluginManager+Registration.swift +++ b/TablePro/Core/Plugins/PluginManager+Registration.swift @@ -341,6 +341,20 @@ extension PluginManager { .capabilities.supportsSchemaSwitching ?? false } + func containerSwitchTarget(for databaseType: DatabaseType) -> ContainerSwitchTarget? { + if supportsDatabaseSwitching(for: databaseType) { + return .database + } + if supportsSchemaSwitching(for: databaseType) { + return .schema + } + return nil + } + + func supportsContainerSwitching(for databaseType: DatabaseType) -> Bool { + containerSwitchTarget(for: databaseType) != nil + } + func supportsImport(for databaseType: DatabaseType) -> Bool { PluginMetadataRegistry.shared.snapshot(forTypeId: databaseType.pluginTypeId)? .capabilities.supportsImport ?? true @@ -376,6 +390,15 @@ extension PluginManager { .schema.tableEntityName ?? "Tables" } + func containerEntityName(for databaseType: DatabaseType) -> String { + PluginMetadataRegistry.shared.snapshot(forTypeId: databaseType.pluginTypeId)? + .schema.containerEntityName ?? "Database" + } + + func containerEntityNamePlural(for databaseType: DatabaseType) -> String { + containerEntityName(for: databaseType) + "s" + } + func supportsCascadeDrop(for databaseType: DatabaseType) -> Bool { PluginMetadataRegistry.shared.snapshot(forTypeId: databaseType.pluginTypeId)? .capabilities.supportsCascadeDrop ?? false diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift index 2ba590656..adc670c1e 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift @@ -38,6 +38,7 @@ extension PluginMetadataRegistry { defaultSchemaName: "", defaultGroupName: "main", tableEntityName: "Tables", + containerEntityName: "Database", defaultPrimaryKeyColumn: nil, immutableColumns: [], systemDatabaseNames: [], @@ -182,6 +183,7 @@ extension PluginMetadataRegistry { defaultSchemaName: "", defaultGroupName: "default", tableEntityName: "Tables", + containerEntityName: "Dataset", defaultPrimaryKeyColumn: nil, immutableColumns: [], systemDatabaseNames: [], @@ -371,6 +373,7 @@ extension PluginMetadataRegistry { defaultSchemaName: "PUBLIC", defaultGroupName: "default", tableEntityName: "Tables", + containerEntityName: "Database", defaultPrimaryKeyColumn: nil, immutableColumns: [], systemDatabaseNames: ["SNOWFLAKE", "SNOWFLAKE_SAMPLE_DATA"], diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index 59ad30091..b73937a97 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -535,6 +535,7 @@ extension PluginMetadataRegistry { defaultSchemaName: "public", defaultGroupName: "main", tableEntityName: "Collections", + containerEntityName: "Database", defaultPrimaryKeyColumn: "_id", immutableColumns: ["_id"], systemDatabaseNames: ["admin", "local", "config"], @@ -617,6 +618,7 @@ extension PluginMetadataRegistry { defaultSchemaName: "public", defaultGroupName: "db0", tableEntityName: "Keys", + containerEntityName: "Database", defaultPrimaryKeyColumn: "Key", immutableColumns: [], systemDatabaseNames: [], @@ -673,6 +675,7 @@ extension PluginMetadataRegistry { defaultSchemaName: "dbo", defaultGroupName: "main", tableEntityName: "Tables", + containerEntityName: "Database", defaultPrimaryKeyColumn: nil, immutableColumns: [], systemDatabaseNames: ["master", "tempdb", "model", "msdb"], @@ -726,6 +729,7 @@ extension PluginMetadataRegistry { defaultSchemaName: "public", defaultGroupName: "main", tableEntityName: "Tables", + containerEntityName: "Database", defaultPrimaryKeyColumn: nil, immutableColumns: [], systemDatabaseNames: [ @@ -787,6 +791,7 @@ extension PluginMetadataRegistry { defaultSchemaName: "public", defaultGroupName: "main", tableEntityName: "Tables", + containerEntityName: "Database", defaultPrimaryKeyColumn: nil, immutableColumns: [], systemDatabaseNames: ["information_schema", "INFORMATION_SCHEMA", "system"], @@ -837,6 +842,7 @@ extension PluginMetadataRegistry { defaultSchemaName: "public", defaultGroupName: "main", tableEntityName: "Tables", + containerEntityName: "Database", defaultPrimaryKeyColumn: nil, immutableColumns: [], systemDatabaseNames: ["information_schema", "pg_catalog"], @@ -889,6 +895,7 @@ extension PluginMetadataRegistry { defaultSchemaName: "public", defaultGroupName: "default", tableEntityName: "Tables", + containerEntityName: "Keyspace", defaultPrimaryKeyColumn: nil, immutableColumns: [], systemDatabaseNames: [ @@ -952,6 +959,7 @@ extension PluginMetadataRegistry { defaultSchemaName: "public", defaultGroupName: "default", tableEntityName: "Tables", + containerEntityName: "Keyspace", defaultPrimaryKeyColumn: nil, immutableColumns: [], systemDatabaseNames: [ @@ -1009,6 +1017,7 @@ extension PluginMetadataRegistry { defaultSchemaName: "public", defaultGroupName: "main", tableEntityName: "Keys", + containerEntityName: "Database", defaultPrimaryKeyColumn: "Key", immutableColumns: ["Version", "ModRevision", "CreateRevision"], systemDatabaseNames: [], @@ -1094,6 +1103,7 @@ extension PluginMetadataRegistry { defaultSchemaName: "main", defaultGroupName: "main", tableEntityName: "Tables", + containerEntityName: "Database", defaultPrimaryKeyColumn: nil, immutableColumns: [], systemDatabaseNames: [], @@ -1153,6 +1163,7 @@ extension PluginMetadataRegistry { defaultSchemaName: "main", defaultGroupName: "main", tableEntityName: "Tables", + containerEntityName: "Database", defaultPrimaryKeyColumn: nil, immutableColumns: [], systemDatabaseNames: [], diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift index a2b859112..4df60842a 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -88,6 +88,7 @@ struct PluginMetadataSnapshot: Sendable { let defaultSchemaName: String let defaultGroupName: String let tableEntityName: String + let containerEntityName: String let defaultPrimaryKeyColumn: String? let immutableColumns: [String] let systemDatabaseNames: [String] @@ -100,6 +101,7 @@ struct PluginMetadataSnapshot: Sendable { defaultSchemaName: "public", defaultGroupName: "main", tableEntityName: "Tables", + containerEntityName: "Database", defaultPrimaryKeyColumn: nil, immutableColumns: [], systemDatabaseNames: [], @@ -453,6 +455,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { defaultSchemaName: "public", defaultGroupName: "main", tableEntityName: "Tables", + containerEntityName: "Database", defaultPrimaryKeyColumn: nil, immutableColumns: [], systemDatabaseNames: ["information_schema", "mysql", "performance_schema", "sys"], @@ -501,6 +504,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { defaultSchemaName: "public", defaultGroupName: "main", tableEntityName: "Tables", + containerEntityName: "Database", defaultPrimaryKeyColumn: nil, immutableColumns: [], systemDatabaseNames: ["information_schema", "mysql", "performance_schema", "sys"], @@ -550,6 +554,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { defaultSchemaName: "public", defaultGroupName: "main", tableEntityName: "Tables", + containerEntityName: "Database", defaultPrimaryKeyColumn: nil, immutableColumns: [], systemDatabaseNames: ["postgres", "template0", "template1"], @@ -598,6 +603,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { defaultSchemaName: "public", defaultGroupName: "main", tableEntityName: "Tables", + containerEntityName: "Database", defaultPrimaryKeyColumn: nil, immutableColumns: [], systemDatabaseNames: ["postgres", "template0", "template1"], @@ -658,6 +664,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { defaultSchemaName: "public", defaultGroupName: "main", tableEntityName: "Tables", + containerEntityName: "Database", defaultPrimaryKeyColumn: nil, immutableColumns: [], systemDatabaseNames: ["postgres", "system", "defaultdb"], @@ -708,6 +715,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { defaultSchemaName: "public", defaultGroupName: "main", tableEntityName: "Tables", + containerEntityName: "Database", defaultPrimaryKeyColumn: nil, immutableColumns: [], systemDatabaseNames: [], @@ -895,6 +903,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { defaultSchemaName: driverType.defaultSchemaName, defaultGroupName: driverType.defaultGroupName, tableEntityName: driverType.tableEntityName, + containerEntityName: driverType.containerEntityName, defaultPrimaryKeyColumn: driverType.defaultPrimaryKeyColumn, immutableColumns: driverType.immutableColumns, systemDatabaseNames: driverType.systemDatabaseNames, diff --git a/TablePro/Core/Plugins/Registry/RegistryModels.swift b/TablePro/Core/Plugins/Registry/RegistryModels.swift index 101a2b622..6dafafa43 100644 --- a/TablePro/Core/Plugins/Registry/RegistryModels.swift +++ b/TablePro/Core/Plugins/Registry/RegistryModels.swift @@ -198,6 +198,7 @@ struct RegistryPluginMetadata: Codable, Sendable { let defaultSchemaName: String? let defaultGroupName: String? let tableEntityName: String? + let containerEntityName: String? let defaultPrimaryKeyColumn: String? let immutableColumns: [String]? diff --git a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Buttons.swift b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Buttons.swift index d7e864d35..036d38693 100644 --- a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Buttons.swift +++ b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Buttons.swift @@ -29,14 +29,15 @@ struct DatabaseToolbarButton: View { var body: some View { let state = coordinator.toolbarState - let supportsSwitch = PluginManager.shared.supportsDatabaseSwitching(for: state.databaseType) + let supportsSwitch = PluginManager.shared.supportsContainerSwitching(for: state.databaseType) + let containerName = PluginManager.shared.containerEntityName(for: state.databaseType) if supportsSwitch { Button { coordinator.commandActions?.openDatabaseSwitcher() } label: { - Label("Database", systemImage: "cylinder") + Label(containerName, systemImage: "cylinder") } - .help(String(localized: "Open Database (⌘K)")) + .help(String(format: String(localized: "Open %@ (⌘K)"), containerName)) .disabled( state.connectionState != .connected || PluginManager.shared.connectionMode(for: state.databaseType) == .fileBased diff --git a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Items.swift b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Items.swift index 0bdbaee0d..9d17313f9 100644 --- a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Items.swift +++ b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Items.swift @@ -21,9 +21,12 @@ extension MainWindowToolbar { } func subitemDatabase() -> NSToolbarItem { - menuOnlyItem( + let containerName = coordinator.map { + PluginManager.shared.containerEntityName(for: $0.toolbarState.databaseType) + } ?? String(localized: "Database") + return menuOnlyItem( id: Self.database, - label: String(localized: "Database"), + label: containerName, symbol: "cylinder", action: #selector(performOpenDatabaseSwitcher(_:)), keyEquivalent: "k", diff --git a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Validation.swift b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Validation.swift index c529f1460..9b3a9eecb 100644 --- a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Validation.swift +++ b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Validation.swift @@ -14,7 +14,7 @@ extension MainWindowToolbar: NSToolbarItemValidation { let hasDataPendingChanges: Bool let blocksAllWrites: Bool let fileBased: Bool - let supportsDatabaseSwitching: Bool + let supportsContainerSwitching: Bool let supportsImport: Bool let supportsServerDashboard: Bool } @@ -24,7 +24,7 @@ extension MainWindowToolbar: NSToolbarItemValidation { case Self.connection, Self.history: return true case Self.database: - return context.connected && !context.fileBased && context.supportsDatabaseSwitching + return context.connected && !context.fileBased && context.supportsContainerSwitching case Self.refresh, Self.quickSwitcher, Self.newTab, Self.exportTables: return context.connected case Self.saveChanges: @@ -51,7 +51,7 @@ extension MainWindowToolbar: NSToolbarItemValidation { hasDataPendingChanges: state.hasDataPendingChanges, blocksAllWrites: state.safeModeLevel.blocksAllWrites, fileBased: PluginManager.shared.connectionMode(for: state.databaseType) == .fileBased, - supportsDatabaseSwitching: PluginManager.shared.supportsDatabaseSwitching(for: state.databaseType), + supportsContainerSwitching: PluginManager.shared.supportsContainerSwitching(for: state.databaseType), supportsImport: PluginManager.shared.supportsImport(for: state.databaseType), supportsServerDashboard: coordinator?.commandActions?.supportsServerDashboard ?? false ) diff --git a/TablePro/Models/Connection/ConnectionToolbarState.swift b/TablePro/Models/Connection/ConnectionToolbarState.swift index bdb6c64e0..15ce5c401 100644 --- a/TablePro/Models/Connection/ConnectionToolbarState.swift +++ b/TablePro/Models/Connection/ConnectionToolbarState.swift @@ -235,6 +235,10 @@ final class ConnectionToolbarState { } return currentDatabase case .byDatabase, .flat, .hierarchicalSchema: + if PluginManager.shared.containerSwitchTarget(for: databaseType) == .schema, + let schema = currentSchema, !schema.isEmpty { + return schema + } return currentDatabase } } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index c3006c2ab..816a4e43f 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -1407,6 +1407,9 @@ } } } + }, + "%@ name" : { + }, "%@ on %@ completed successfully." : { "localizations" : { @@ -1568,6 +1571,9 @@ } } } + }, + "%@ Switch Failed" : { + }, "%@ was not found on this system. Install it with `brew install libpq` and link it." : { "localizations" : { @@ -18224,6 +18230,7 @@ } }, "Database name" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -18401,6 +18408,7 @@ } }, "Database Switch Failed" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -21177,6 +21185,15 @@ } } } + }, + "Drop %@" : { + + }, + "Drop %@…" : { + + }, + "Drop %1$@ “%2$@”?" : { + }, "Drop %d tables" : { "localizations" : { @@ -21246,6 +21263,7 @@ } }, "Drop Database" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -21291,6 +21309,7 @@ } }, "Drop database “%@”?" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -21336,6 +21355,7 @@ } }, "Drop Database…" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -25907,6 +25927,9 @@ } } } + }, + "Failed to load %@" : { + }, "Failed to load databases" : { "localizations" : { @@ -34739,6 +34762,9 @@ } } } + }, + "Loading %@…" : { + }, "Loading dashboard..." : { "localizations" : { @@ -37666,6 +37692,12 @@ } } } + }, + "New %@" : { + + }, + "New %@ (⌘N)" : { + }, "New %@ Connection" : { "localizations" : { @@ -37688,6 +37720,9 @@ } } } + }, + "New %@…" : { + }, "New Chat" : { "extractionState" : "stale", @@ -37801,6 +37836,7 @@ } }, "New Database" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -37823,6 +37859,7 @@ } }, "New Database (⌘N)" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -37845,6 +37882,7 @@ } }, "New Database…" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -38420,6 +38458,9 @@ } } } + }, + "No %1$@ match “%2$@”" : { + }, "No activations found" : { "localizations" : { @@ -41350,6 +41391,9 @@ } } } + }, + "Open %@ (⌘K)" : { + }, "Open %@ Editor" : { "localizations" : { @@ -41394,6 +41438,9 @@ } } } + }, + "Open %@..." : { + }, "Open a connection to insert" : { "localizations" : { @@ -41639,6 +41686,7 @@ } }, "Open Database (⌘K)" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -41661,6 +41709,7 @@ } }, "Open Database..." : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -42692,6 +42741,9 @@ } } } + }, + "Over an SSH tunnel, TablePro connects directly to the first host. Replica set failover is not available." : { + }, "Override auto-detected CLI paths per database type." : { "extractionState" : "stale", @@ -51949,6 +52001,7 @@ } }, "Schema Switch Failed" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -52102,6 +52155,9 @@ } } } + }, + "Search %@" : { + }, "Search activity" : { "localizations" : { @@ -56785,6 +56841,7 @@ } }, "SSH tunneling only forwards the first host. Other replica set members must be directly reachable from the SSH server." : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -57881,6 +57938,12 @@ } } } + }, + "switch %@" : { + + }, + "Switch %@" : { + }, "Switch connection" : { "extractionState" : "stale", @@ -57972,6 +58035,7 @@ } }, "switch database" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -60601,6 +60665,9 @@ } } } + }, + "This %1$@ has no %2$@ yet." : { + }, "This action needs a modifier key like ⌘ or ⌥. A plain key won't reach the menu reliably." : { "localizations" : { @@ -60669,6 +60736,7 @@ } }, "This database has no %@ yet." : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index d0dde7d56..628413cde 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -154,6 +154,13 @@ struct AppMenuCommands: Commands { settingsManager.keyboard.keyboardShortcut(for: action) } + private var openContainerMenuTitle: String { + let containerName = actions.map { + PluginManager.shared.containerEntityName(for: $0.currentDatabaseType) + } ?? "Database" + return String(format: String(localized: "Open %@..."), containerName) + } + /// Prefers the focused scene value; falls back to the coordinator back-reference /// so Cmd+W still routes through `closeTab()` (with its unsaved-changes dialog) /// when focus is inside an AppKit subview and `@FocusedValue` has not resolved. @@ -266,11 +273,11 @@ struct AppMenuCommands: Commands { } .disabled(!(actions?.isConnected ?? false) || actions?.isReadOnly ?? false) - Button("Open Database...") { + Button(openContainerMenuTitle) { actions?.openDatabaseSwitcher() } .optionalKeyboardShortcut(shortcut(for: .openDatabase)) - .disabled(!(actions?.isConnected ?? false) || !(actions?.supportsDatabaseSwitching ?? false)) + .disabled(!(actions?.isConnected ?? false) || !(actions?.supportsContainerSwitching ?? false)) Button(String(localized: "Open File...")) { actions?.openSQLFile() diff --git a/TablePro/ViewModels/DatabaseSwitcherViewModel.swift b/TablePro/ViewModels/DatabaseSwitcherViewModel.swift index 9849990bf..663d7bd52 100644 --- a/TablePro/ViewModels/DatabaseSwitcherViewModel.swift +++ b/TablePro/ViewModels/DatabaseSwitcherViewModel.swift @@ -21,6 +21,8 @@ final class DatabaseSwitcherViewModel { var errorMessage: String? var showPreview = false + let switchTarget: ContainerSwitchTarget + private let connectionId: UUID private let currentDatabase: String? private let databaseType: DatabaseType @@ -45,6 +47,7 @@ final class DatabaseSwitcherViewModel { self.currentDatabase = currentDatabase self.databaseType = databaseType self.services = services + self.switchTarget = services.pluginManager.containerSwitchTarget(for: databaseType) ?? .database } func fetchDatabases() async { @@ -52,16 +55,21 @@ final class DatabaseSwitcherViewModel { errorMessage = nil do { - let dbNames = try await services.databaseManager.withMetadataDriver(connectionId: connectionId) { driver in - try await driver.fetchDatabases() + let target = switchTarget + let names = try await services.databaseManager.withMetadataDriver(connectionId: connectionId) { driver in + switch target { + case .database: try await driver.fetchDatabases() + case .schema: try await driver.fetchSchemas() + } } - databases = dbNames.sorted().map { name in + databases = names.sorted().map { name in DatabaseMetadata.minimal(name: name, isSystem: isSystemItem(name)) } preselectDatabase() isLoading = false + guard switchTarget == .database else { return } do { let metadataList = try await services.databaseManager.withMetadataDriver(connectionId: connectionId, workload: .bulk) { driver in try await driver.fetchAllDatabaseMetadata() @@ -135,6 +143,9 @@ final class DatabaseSwitcherViewModel { } private func isSystemItem(_ name: String) -> Bool { - services.pluginManager.systemDatabaseNames(for: databaseType).contains(name) + switch switchTarget { + case .database: services.pluginManager.systemDatabaseNames(for: databaseType).contains(name) + case .schema: services.pluginManager.systemSchemaNames(for: databaseType).contains(name) + } } } diff --git a/TablePro/ViewModels/QuickSwitcherViewModel.swift b/TablePro/ViewModels/QuickSwitcherViewModel.swift index da6c16107..696387acc 100644 --- a/TablePro/ViewModels/QuickSwitcherViewModel.swift +++ b/TablePro/ViewModels/QuickSwitcherViewModel.swift @@ -104,16 +104,20 @@ internal final class QuickSwitcherViewModel { )) } + let switchTarget = services.pluginManager.containerSwitchTarget(for: databaseType) do { let databases = try await services.databaseManager.withMetadataDriver(connectionId: connectionId) { driver in try await driver.fetchDatabases() } + let databaseSubtitle = switchTarget == .database + ? services.pluginManager.containerEntityName(for: databaseType) + : String(localized: "Database") for db in databases { items.append(QuickSwitcherItem( id: "db_\(db)", name: db, kind: .database, - subtitle: String(localized: "Database") + subtitle: databaseSubtitle )) } } catch { @@ -125,12 +129,15 @@ internal final class QuickSwitcherViewModel { let schemas = try await services.databaseManager.withMetadataDriver(connectionId: connectionId) { driver in try await driver.fetchSchemas() } + let schemaSubtitle = switchTarget == .schema + ? services.pluginManager.containerEntityName(for: databaseType) + : String(localized: "Schema") for schema in schemas { items.append(QuickSwitcherItem( id: "schema_\(schema)", name: schema, kind: .schema, - subtitle: String(localized: "Schema") + subtitle: schemaSubtitle )) } } catch { diff --git a/TablePro/Views/DatabaseSwitcher/CreateDatabaseSheet.swift b/TablePro/Views/DatabaseSwitcher/CreateDatabaseSheet.swift index 3f8254b6e..b4ff73663 100644 --- a/TablePro/Views/DatabaseSwitcher/CreateDatabaseSheet.swift +++ b/TablePro/Views/DatabaseSwitcher/CreateDatabaseSheet.swift @@ -49,7 +49,7 @@ struct CreateDatabaseSheet: View { private var titleRow: some View { HStack { - Text(String(localized: "New Database")) + Text(String(format: String(localized: "New %@"), containerEntityName)) .font(.headline) Spacer() } @@ -57,13 +57,17 @@ struct CreateDatabaseSheet: View { .padding(.vertical, 14) } + private var containerEntityName: String { + PluginManager.shared.containerEntityName(for: databaseType) + } + @ViewBuilder private var formBody: some View { Form { TextField( String(localized: "Name"), text: $databaseName, - prompt: Text(String(localized: "Database name")) + prompt: Text(String(format: String(localized: "%@ name"), containerEntityName)) ) switch loadState { diff --git a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift index 22fb1110a..8a2e6748e 100644 --- a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift +++ b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift @@ -9,14 +9,18 @@ struct DatabaseSwitcherPopoverHost: View { if let coordinator { let connection = coordinator.connection let session = DatabaseManager.shared.session(for: connection.id) - let activeDatabase = session?.currentDatabase ?? connection.database + let switchTarget = PluginManager.shared.containerSwitchTarget(for: connection.type) ?? .database + let activeContainer: String? = switch switchTarget { + case .database: session?.currentDatabase ?? connection.database + case .schema: coordinator.toolbarState.currentSchema ?? session?.currentSchema + } DatabaseSwitcherPopover( - currentDatabase: activeDatabase, + currentDatabase: activeContainer, databaseType: connection.type, connectionId: connection.id, - onSelect: { [weak coordinator] database in - Task { await coordinator?.switchDatabase(to: database) } + onSelect: { [weak coordinator] container in + Task { await coordinator?.switchContainer(to: container) } }, onRequestCreate: { [weak coordinator] in coordinator?.activeSheet = .createDatabase @@ -52,6 +56,12 @@ struct DatabaseSwitcherPopover: View { private var showsCreateRow: Bool { supportsCreateDatabase } + private var containerName: String { + PluginManager.shared.containerEntityName(for: databaseType) + } + private var containerNamePlural: String { + PluginManager.shared.containerEntityNamePlural(for: databaseType) + } init( currentDatabase: String?, @@ -105,7 +115,7 @@ struct DatabaseSwitcherPopover: View { private var searchField: some View { NativeSearchField( text: $viewModel.searchText, - placeholder: String(localized: "Search databases"), + placeholder: String(format: String(localized: "Search %@"), containerNamePlural.lowercased()), onMoveUp: { viewModel.moveUp() }, onMoveDown: { viewModel.moveDown() }, onSubmit: { commitSelection() }, @@ -196,7 +206,7 @@ struct DatabaseSwitcherPopover: View { dismiss() onRequestDrop(database.name) } label: { - Label(String(localized: "Drop Database…"), systemImage: "trash") + Label(String(format: String(localized: "Drop %@…"), containerName), systemImage: "trash") } } } @@ -204,7 +214,7 @@ struct DatabaseSwitcherPopover: View { private var loadingView: some View { VStack(spacing: 10) { ProgressView().controlSize(.small) - Text(String(localized: "Loading databases…")) + Text(String(format: String(localized: "Loading %@…"), containerNamePlural.lowercased())) .font(.callout) .foregroundStyle(.secondary) } @@ -216,7 +226,7 @@ struct DatabaseSwitcherPopover: View { Image(systemName: "exclamationmark.triangle") .font(.title3) .foregroundStyle(.orange) - Text(String(localized: "Failed to load databases")) + Text(String(format: String(localized: "Failed to load %@"), containerNamePlural.lowercased())) .font(.callout.weight(.medium)) Text(message) .font(.caption) @@ -255,13 +265,17 @@ struct DatabaseSwitcherPopover: View { .font(.title3) .foregroundStyle(.secondary) if viewModel.searchText.isEmpty { - Text(String(localized: "No databases")) + Text(String(format: String(localized: "No %@"), containerNamePlural.lowercased())) .font(.callout.weight(.medium)) } else { - Text(String(format: String(localized: "No databases match “%@”"), viewModel.searchText)) - .font(.callout) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) + Text(String( + format: String(localized: "No %1$@ match “%2$@”"), + containerNamePlural.lowercased(), + viewModel.searchText + )) + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) } } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -274,10 +288,10 @@ struct DatabaseSwitcherPopover: View { dismiss() onRequestCreate() } label: { - Label(String(localized: "New Database…"), systemImage: "plus") + Label(String(format: String(localized: "New %@…"), containerName), systemImage: "plus") } .buttonStyle(.borderless) - .help(String(localized: "New Database (⌘N)")) + .help(String(format: String(localized: "New %@ (⌘N)"), containerName)) .keyboardShortcut("n", modifiers: .command) Spacer() diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 1bdbce232..17ba1aa40 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -833,7 +833,7 @@ struct MainEditorContentView: View { .foregroundStyle(.quaternary) } - if PluginManager.shared.supportsDatabaseSwitching(for: connection.type) { + if PluginManager.shared.supportsContainerSwitching(for: connection.type) { HStack(spacing: 6) { Text("⌘K") .font(.callout.monospaced()) @@ -844,9 +844,12 @@ struct MainEditorContentView: View { RoundedRectangle(cornerRadius: 4) .fill(Color(nsColor: .quaternaryLabelColor)) ) - Text("Switch Database") - .font(.callout) - .foregroundStyle(.tertiary) + Text(String( + format: String(localized: "Switch %@"), + PluginManager.shared.containerEntityName(for: connection.type) + )) + .font(.callout) + .foregroundStyle(.tertiary) } } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 81880558d..609ec17a2 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -401,13 +401,34 @@ extension MainContentCoordinator { navigationLogger.error("Failed to switch database: \(error.localizedDescription, privacy: .public)") AlertHelper.showErrorSheet( - title: String(localized: "Database Switch Failed"), + title: String( + format: String(localized: "%@ Switch Failed"), + PluginManager.shared.containerEntityName(for: connection.type) + ), message: error.localizedDescription, window: contentWindow ) } } + /// Switch the active container (database, or schema for schema-switching-only + /// engines like BigQuery), routing by the plugin's container switch target. + func switchContainer(to container: String) async { + switch PluginManager.shared.containerSwitchTarget(for: connection.type) { + case .schema: + await switchSchema(to: container) + case .database, nil: + await switchDatabase(to: container) + } + } + + private var schemaEntityName: String { + guard PluginManager.shared.containerSwitchTarget(for: connection.type) == .schema else { + return String(localized: "Schema") + } + return PluginManager.shared.containerEntityName(for: connection.type) + } + func switchSchema(to schema: String) async { guard PluginManager.shared.supportsSchemaSwitching(for: connection.type) else { navigationLogger.warning( @@ -441,7 +462,7 @@ extension MainContentCoordinator { navigationLogger.error("Failed to switch schema: \(error.localizedDescription, privacy: .public)") AlertHelper.showErrorSheet( - title: String(localized: "Schema Switch Failed"), + title: String(format: String(localized: "%@ Switch Failed"), schemaEntityName), message: error.localizedDescription, window: contentWindow ) diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 1d0de4c27..1f11efa63 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -255,8 +255,8 @@ final class MainContentCommandActions { var currentDatabaseType: DatabaseType { connection.type } - var supportsDatabaseSwitching: Bool { - PluginManager.shared.supportsDatabaseSwitching(for: connection.type) + var supportsContainerSwitching: Bool { + PluginManager.shared.supportsContainerSwitching(for: connection.type) } var canSwitchSidebarLayout: Bool { @@ -850,7 +850,7 @@ final class MainContentCommandActions { func openDatabaseSwitcher() { guard let coordinator else { return } let type = coordinator.connection.type - guard PluginManager.shared.supportsDatabaseSwitching(for: type) else { return } + guard PluginManager.shared.supportsContainerSwitching(for: type) else { return } guard PluginManager.shared.connectionMode(for: type) != .fileBased else { return } coordinator.contentWindow?.makeFirstResponder(nil) coordinator.isDatabaseSwitcherShown = true diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 89d2b72a7..d57475024 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -108,7 +108,7 @@ struct MainContentView: View { titleVisibility: .visible, presenting: coordinator.databaseToDrop ) { name in - Button(String(localized: "Drop Database"), role: .destructive) { + Button(String(format: String(localized: "Drop %@"), containerEntityName), role: .destructive) { Task { await dropDatabase(name: name) } } Button(String(localized: "Cancel"), role: .cancel) { @@ -131,11 +131,19 @@ struct MainContentView: View { private var dropConfirmationTitle: String { if let name = coordinator.databaseToDrop { - return String(format: String(localized: "Drop database “%@”?"), name) + 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 @@ -179,7 +187,7 @@ struct MainContentView: View { databaseType: connection.type, viewModel: viewModel, onCreated: { newDatabaseName in - Task { await coordinator.switchDatabase(to: newDatabaseName) } + Task { await coordinator.switchContainer(to: newDatabaseName) } } ) case .exportDialog: diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 9772b16c7..036c4298b 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -276,8 +276,13 @@ struct SidebarView: View { private var emptyState: some View { let entityName = PluginManager.shared.tableEntityName(for: viewModel.databaseType) + let containerName = PluginManager.shared.containerEntityName(for: viewModel.databaseType) let noItemsLabel = String(format: String(localized: "No %@"), entityName) - let noItemsDetail = String(format: String(localized: "This database has no %@ yet."), entityName.lowercased()) + let noItemsDetail = String( + format: String(localized: "This %1$@ has no %2$@ yet."), + containerName.lowercased(), + entityName.lowercased() + ) return ContentUnavailableView( noItemsLabel, systemImage: "tablecells", diff --git a/TablePro/Views/Toolbar/ConnectionStatusView.swift b/TablePro/Views/Toolbar/ConnectionStatusView.swift index 271140ca8..886e6bd35 100644 --- a/TablePro/Views/Toolbar/ConnectionStatusView.swift +++ b/TablePro/Views/Toolbar/ConnectionStatusView.swift @@ -58,7 +58,7 @@ struct ConnectionStatusView: View { @ViewBuilder private var chipSection: some View { - if !PluginManager.shared.supportsDatabaseSwitching(for: databaseType) { + if !PluginManager.shared.supportsContainerSwitching(for: databaseType) { chipLabel .help(staticChipTooltip) } else { @@ -89,7 +89,8 @@ struct ConnectionStatusView: View { private var chipKindLabel: String { switch databaseGroupingStrategy { case .bySchema: return String(localized: "Schema") - case .byDatabase, .flat, .hierarchicalSchema: return String(localized: "Database") + case .byDatabase, .flat, .hierarchicalSchema: + return PluginManager.shared.containerEntityName(for: databaseType) } } @@ -100,7 +101,8 @@ struct ConnectionStatusView: View { private var switchableChipTooltip: String { let switchVerb: String = switch databaseGroupingStrategy { case .bySchema: String(localized: "switch schema") - case .byDatabase, .flat, .hierarchicalSchema: String(localized: "switch database") + case .byDatabase, .flat, .hierarchicalSchema: + String(format: String(localized: "switch %@"), chipKindLabel.lowercased()) } if safeModeLevel == .readOnly { return String( diff --git a/TableProTests/Core/Plugins/ContainerEntityNameTests.swift b/TableProTests/Core/Plugins/ContainerEntityNameTests.swift new file mode 100644 index 000000000..078a737a4 --- /dev/null +++ b/TableProTests/Core/Plugins/ContainerEntityNameTests.swift @@ -0,0 +1,85 @@ +// +// ContainerEntityNameTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@MainActor +@Suite("Container entity name and switch target") +struct ContainerEntityNameTests { + private func snapshot(forTypeId typeId: String) -> PluginMetadataSnapshot? { + PluginMetadataRegistry.shared.snapshot(forTypeId: typeId) + } + + // MARK: - Container entity names + + @Test("BigQuery container is Dataset") + func bigQueryContainerIsDataset() { + #expect(PluginManager.shared.containerEntityName(for: .bigQuery) == "Dataset") + } + + @Test("Cassandra and ScyllaDB containers are Keyspace") + func cassandraFamilyContainerIsKeyspace() { + #expect(PluginManager.shared.containerEntityName(for: .cassandra) == "Keyspace") + #expect(PluginManager.shared.containerEntityName(for: .scylladb) == "Keyspace") + } + + @Test("Relational engines keep Database as container") + func relationalEnginesUseDatabase() { + for type in [DatabaseType.mysql, .postgresql, .sqlite, .mssql, .clickhouse] { + #expect(PluginManager.shared.containerEntityName(for: type) == "Database") + } + } + + @Test("Unknown type falls back to Database") + func unknownTypeFallsBackToDatabase() { + let unknown = DatabaseType(rawValue: "FuturePlugin") + #expect(PluginManager.shared.containerEntityName(for: unknown) == "Database") + } + + @Test("Plural form appends s") + func pluralFormAppendsS() { + #expect(PluginManager.shared.containerEntityNamePlural(for: .bigQuery) == "Datasets") + #expect(PluginManager.shared.containerEntityNamePlural(for: .cassandra) == "Keyspaces") + #expect(PluginManager.shared.containerEntityNamePlural(for: .mysql) == "Databases") + } + + @Test("DriverPlugin defaults provide Database container") + func driverPluginDefaultIsDatabase() { + #expect(snapshot(forTypeId: "MySQL")?.schema.containerEntityName == "Database") + #expect(PluginMetadataSnapshot.SchemaInfo.defaults.containerEntityName == "Database") + } + + // MARK: - Container switch target + + @Test("Database-switching engines target databases") + func databaseSwitchingEnginesTargetDatabases() { + #expect(PluginManager.shared.containerSwitchTarget(for: .mysql) == .database) + #expect(PluginManager.shared.containerSwitchTarget(for: .cassandra) == .database) + } + + @Test("Schema-switching-only engines target schemas") + func schemaOnlyEnginesTargetSchemas() { + #expect(PluginManager.shared.containerSwitchTarget(for: .bigQuery) == .schema) + } + + @Test("Engines supporting both prefer databases") + func dualModeEnginesPreferDatabases() { + #expect(PluginManager.shared.containerSwitchTarget(for: .postgresql) == .database) + } + + @Test("Engines without switching have no target") + func nonSwitchingEnginesHaveNoTarget() { + #expect(PluginManager.shared.containerSwitchTarget(for: .redis) == nil) + #expect(PluginManager.shared.supportsContainerSwitching(for: .redis) == false) + } + + @Test("BigQuery supports container switching through schemas") + func bigQuerySupportsContainerSwitching() { + #expect(PluginManager.shared.supportsContainerSwitching(for: .bigQuery) == true) + } +} diff --git a/TableProTests/Services/MainWindowToolbarValidationTests.swift b/TableProTests/Services/MainWindowToolbarValidationTests.swift index dbeb914b1..a6c348170 100644 --- a/TableProTests/Services/MainWindowToolbarValidationTests.swift +++ b/TableProTests/Services/MainWindowToolbarValidationTests.swift @@ -18,7 +18,7 @@ struct MainWindowToolbarValidationTests { hasDataPendingChanges: Bool = false, blocksAllWrites: Bool = false, fileBased: Bool = false, - supportsDatabaseSwitching: Bool = true, + supportsContainerSwitching: Bool = true, supportsImport: Bool = true, supportsServerDashboard: Bool = true ) -> MainWindowToolbar.ValidationContext { @@ -29,7 +29,7 @@ struct MainWindowToolbarValidationTests { hasDataPendingChanges: hasDataPendingChanges, blocksAllWrites: blocksAllWrites, fileBased: fileBased, - supportsDatabaseSwitching: supportsDatabaseSwitching, + supportsContainerSwitching: supportsContainerSwitching, supportsImport: supportsImport, supportsServerDashboard: supportsServerDashboard ) @@ -81,8 +81,8 @@ struct MainWindowToolbarValidationTests { @Test("Database switcher requires plugin support") func databaseRequiresPluginSupport() { - let unsupported = makeContext(connected: true, supportsDatabaseSwitching: false) - let supported = makeContext(connected: true, supportsDatabaseSwitching: true) + let unsupported = makeContext(connected: true, supportsContainerSwitching: false) + let supported = makeContext(connected: true, supportsContainerSwitching: true) #expect(MainWindowToolbar.isEnabled(itemIdentifier: MainWindowToolbar.database, context: unsupported) == false) #expect(MainWindowToolbar.isEnabled(itemIdentifier: MainWindowToolbar.database, context: supported) == true) } diff --git a/docs/databases/bigquery.mdx b/docs/databases/bigquery.mdx index ba37320e4..fd2033649 100644 --- a/docs/databases/bigquery.mdx +++ b/docs/databases/bigquery.mdx @@ -50,6 +50,8 @@ OAuth tokens are session-only. You'll need to re-authorize after disconnecting. **Dataset Browsing**: The sidebar lists every dataset as an expandable node. Click a dataset to load its tables; they load the first time you open it. Search filters across the datasets you have open. +**Dataset Switching**: Press `Cmd+K`, click the **Dataset** toolbar button, or use **File** > **Open Dataset...** to jump to another dataset. The switcher also creates new datasets (`Cmd+N` inside the popover) and drops datasets from the row context menu. + **Table Structure**: Columns with full BigQuery types: `STRUCT`, `ARRAY`, nullable status, field descriptions. Clustering and partitioning info in the Indexes tab. **GoogleSQL Queries** ([docs](https://cloud.google.com/bigquery/docs/reference/standard-sql/query-syntax)):