diff --git a/CHANGELOG.md b/CHANGELOG.md index 17fb52f4f..39fb14398 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- The tree sidebar can show only the databases you pick. Use the filter button to check the ones you want, with a search box for long lists. The choice is saved per connection. (#1667) + ## [0.51.0] - 2026-06-13 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index ffb0822de..d452b76a6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -182,6 +182,7 @@ Missing a case produces a wrong "{Language} Query" title on the first frame. | Filter presets | UserDefaults | `FilterPresetStorage` | | Per-table filters | JSON files | `FilterSettingsStorage` (one file per connection + database + schema + table; saves the valid working set, each row's enabled flag included) | | Favorite tables | UserDefaults | `FavoriteTablesStorage` (per connection + database + schema; iCloud-synced) | +| Tree database filter | UserDefaults | `DatabaseTreeFilterStorage` (per connection; selected database set, empty = show all; device-local). Live value held in `SharedSidebarState`. | ### Logging & Debugging diff --git a/TablePro/Core/Services/Query/DatabaseTreeVisibility.swift b/TablePro/Core/Services/Query/DatabaseTreeVisibility.swift new file mode 100644 index 000000000..8c7352186 --- /dev/null +++ b/TablePro/Core/Services/Query/DatabaseTreeVisibility.swift @@ -0,0 +1,13 @@ +import Foundation + +enum DatabaseTreeVisibility { + static func visible(databases: [DatabaseMetadata], selected: Set) -> [DatabaseMetadata] { + let nonSystem = databases.filter { !$0.isSystemDatabase } + guard !selected.isEmpty else { return nonSystem } + return nonSystem.filter { selected.contains($0.name) } + } + + static func isFiltering(selected: Set) -> Bool { + !selected.isEmpty + } +} diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index 686965536..5e154cbfd 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -256,6 +256,7 @@ final class ConnectionStorage { FavoriteTablesStorage.shared.removeFavorites(for: connection.id) FilterSettingsStorage.shared.removeFilters(for: connection.id) + DatabaseTreeFilterStorage.shared.removeFilter(for: connection.id) Task { await SQLFavoriteManager.shared.removeFavoritesAndFolders(for: connection.id) } @@ -289,6 +290,7 @@ final class ConnectionStorage { FavoriteTablesStorage.shared.removeFavorites(for: conn.id) } FilterSettingsStorage.shared.removeFilters(for: idsToDelete) + DatabaseTreeFilterStorage.shared.removeFilters(for: idsToDelete) Task { for conn in connectionsToDelete { await SQLFavoriteManager.shared.removeFavoritesAndFolders(for: conn.id) diff --git a/TablePro/Core/Storage/DatabaseTreeFilterStorage.swift b/TablePro/Core/Storage/DatabaseTreeFilterStorage.swift new file mode 100644 index 000000000..90610afaa --- /dev/null +++ b/TablePro/Core/Storage/DatabaseTreeFilterStorage.swift @@ -0,0 +1,41 @@ +import Foundation + +@MainActor +final class DatabaseTreeFilterStorage { + static let shared = DatabaseTreeFilterStorage() + + private let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + private func databasesKey(connectionId: UUID) -> String { + "com.TablePro.treeDatabaseFilter.\(connectionId.uuidString).selected" + } + + func selectedDatabases(connectionId: UUID) -> Set { + guard let data = defaults.data(forKey: databasesKey(connectionId: connectionId)), + let names = try? JSONDecoder().decode([String].self, from: data) + else { return [] } + return Set(names) + } + + func setSelectedDatabases(_ databases: Set, connectionId: UUID) { + let key = databasesKey(connectionId: connectionId) + guard !databases.isEmpty else { + defaults.removeObject(forKey: key) + return + } + guard let data = try? JSONEncoder().encode(databases.sorted()) else { return } + defaults.set(data, forKey: key) + } + + func removeFilter(for connectionId: UUID) { + defaults.removeObject(forKey: databasesKey(connectionId: connectionId)) + } + + func removeFilters(for connectionIds: Set) { + for id in connectionIds { removeFilter(for: id) } + } +} diff --git a/TablePro/Models/UI/SharedSidebarState.swift b/TablePro/Models/UI/SharedSidebarState.swift index 1dbb65917..235696802 100644 --- a/TablePro/Models/UI/SharedSidebarState.swift +++ b/TablePro/Models/UI/SharedSidebarState.swift @@ -42,6 +42,15 @@ final class SharedSidebarState { } } + var databaseFilterSelected: Set { + didSet { + DatabaseTreeFilterStorage.shared.setSelectedDatabases( + databaseFilterSelected, + connectionId: connectionId + ) + } + } + static var defaultLayout: SidebarLayout { get { guard let raw = UserDefaults.standard.string(forKey: SidebarPersistenceKey.defaultLayout), @@ -73,6 +82,7 @@ final class SharedSidebarState { } else { self.sidebarLayout = SharedSidebarState.defaultLayout } + self.databaseFilterSelected = DatabaseTreeFilterStorage.shared.selectedDatabases(connectionId: connectionId) } /// Default init for previews and tests @@ -80,6 +90,7 @@ final class SharedSidebarState { self.connectionId = UUID() self.selectedSidebarTab = .tables self.sidebarLayout = .flat + self.databaseFilterSelected = [] } private static var registry: [UUID: SharedSidebarState] = [:] diff --git a/TablePro/Views/Sidebar/DatabaseTreeFilterPopover.swift b/TablePro/Views/Sidebar/DatabaseTreeFilterPopover.swift new file mode 100644 index 000000000..8f4b863e9 --- /dev/null +++ b/TablePro/Views/Sidebar/DatabaseTreeFilterPopover.swift @@ -0,0 +1,123 @@ +// +// DatabaseTreeFilterPopover.swift +// TablePro +// + +import SwiftUI + +struct DatabaseTreeFilterPopover: View { + let connectionId: UUID + + @Binding var selectedDatabases: Set + + @Bindable private var treeService = DatabaseTreeMetadataService.shared + @State private var searchText: String = "" + + private static let width: CGFloat = 300 + + private var selectableDatabases: [DatabaseMetadata] { + treeService.databases(for: connectionId) + .filter { !$0.isSystemDatabase } + } + + private var matchingDatabases: [DatabaseMetadata] { + guard !searchText.isEmpty else { return selectableDatabases } + return selectableDatabases.filter { FuzzyMatcher.matches(query: searchText, candidate: $0.name) } + } + + private var shownCount: Int { + guard !selectedDatabases.isEmpty else { return selectableDatabases.count } + return selectableDatabases.filter { selectedDatabases.contains($0.name) }.count + } + + var body: some View { + VStack(spacing: 0) { + searchField + + Divider() + + content + + Divider() + + footer + } + .frame(width: Self.width) + } + + private var searchField: some View { + NativeSearchField( + text: $searchText, + placeholder: String(localized: "Search databases"), + controlSize: .small + ) + .padding(.horizontal, 8) + .padding(.vertical, 6) + } + + @ViewBuilder + private var content: some View { + if selectableDatabases.isEmpty { + ContentUnavailableView( + String(localized: "No Databases"), + systemImage: "cylinder", + description: Text(String(localized: "Connect to load the database list.")) + ) + .frame(maxWidth: .infinity, minHeight: 160) + } else if matchingDatabases.isEmpty { + ContentUnavailableView.search(text: searchText) + .frame(maxWidth: .infinity, minHeight: 160) + } else { + databaseList + } + } + + private var databaseList: some View { + List { + ForEach(matchingDatabases, id: \.name) { db in + Toggle(db.name, isOn: databaseBinding(for: db.name)) + .toggleStyle(.checkbox) + .lineLimit(1) + .truncationMode(.middle) + } + } + .listStyle(.inset) + .scrollContentBackground(.hidden) + .frame(minHeight: 160, maxHeight: 320) + } + + private var footer: some View { + HStack(spacing: 12) { + Text(countLabel) + .font(.caption) + .foregroundStyle(.secondary) + + Spacer() + + Button(String(localized: "Show All")) { + selectedDatabases = [] + } + .buttonStyle(.borderless) + .disabled(selectedDatabases.isEmpty) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + + private var countLabel: String { + guard !selectedDatabases.isEmpty else { + return String(localized: "Showing all databases") + } + return String(format: String(localized: "Showing %1$lld of %2$lld"), shownCount, selectableDatabases.count) + } + + private func databaseBinding(for database: String) -> Binding { + Binding( + get: { selectedDatabases.contains(database) }, + set: { isOn in + if isOn { selectedDatabases.insert(database) } + else { selectedDatabases.remove(database) } + } + ) + } +} diff --git a/TablePro/Views/Sidebar/DatabaseTreeView.swift b/TablePro/Views/Sidebar/DatabaseTreeView.swift index 735f82ec7..d0f55f462 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeView.swift @@ -53,6 +53,7 @@ struct DatabaseTreeView: View { @Binding var pendingTruncates: Set @Binding var pendingDeletes: Set let coordinator: MainContentCoordinator? + let sidebarState: SharedSidebarState @State private var localSelection: Set = [] @State private var searchText: String = "" @@ -104,6 +105,8 @@ struct DatabaseTreeView: View { errorState(message: message) case .loaded where databases.isEmpty: emptyDatabasesState + case .loaded where isFilterHidingEverything: + filteredEmptyState case .loaded: treeList case .idle, .loading: @@ -317,6 +320,24 @@ struct DatabaseTreeView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } + private var isFilterHidingEverything: Bool { + DatabaseTreeVisibility.isFiltering(selected: sidebarState.databaseFilterSelected) + && filteredDatabases.isEmpty + } + + private var filteredEmptyState: some View { + ContentUnavailableView { + Label(String(localized: "No Databases Shown"), systemImage: "line.3.horizontal.decrease.circle") + } description: { + Text("The database filter hides every database on this connection.") + } actions: { + Button(String(localized: "Show All")) { + sidebarState.databaseFilterSelected = [] + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + private func loadingRow(_ text: String) -> some View { HStack(spacing: 8) { ProgressView() @@ -434,9 +455,12 @@ struct DatabaseTreeView: View { treeService.routines(connectionId: connectionId, database: database, schema: schema) } + private var filteredDatabases: [DatabaseMetadata] { + DatabaseTreeVisibility.visible(databases: databases, selected: sidebarState.databaseFilterSelected) + } + private var visibleDatabases: [DatabaseMetadata] { - let nonSystem = databases.filter { !$0.isSystemDatabase } - let matched = searchText.isEmpty ? nonSystem : nonSystem.filter { databaseMatchesSearch($0) } + let matched = searchText.isEmpty ? filteredDatabases : filteredDatabases.filter { databaseMatchesSearch($0) } var seen = Set() return matched.filter { seen.insert($0.id).inserted } } diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 036c4298b..eb374eed8 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -11,6 +11,7 @@ import TableProPluginKit struct SidebarView: View { @State private var viewModel: SidebarViewModel @State private var favoriteTables: Set = [] + @State private var showDatabaseFilter: Bool = false private var schemaService: SchemaService { SchemaService.shared } @@ -165,6 +166,9 @@ struct SidebarView: View { Divider() HStack(spacing: 8) { createObjectMenu + if usesDatabaseTree { + databaseFilterButton + } Spacer() if supportsSchemaFooter { SchemaPickerControl( @@ -179,6 +183,37 @@ struct SidebarView: View { } } + private var isDatabaseFilterActive: Bool { + !sidebarState.databaseFilterSelected.isEmpty + } + + private var databaseFilterSelectionBinding: Binding> { + Binding( + get: { sidebarState.databaseFilterSelected }, + set: { sidebarState.databaseFilterSelected = $0 } + ) + } + + private var databaseFilterButton: some View { + Button { + showDatabaseFilter = true + } label: { + Image(systemName: isDatabaseFilterActive + ? "line.3.horizontal.decrease.circle.fill" + : "line.3.horizontal.decrease.circle") + .foregroundStyle(isDatabaseFilterActive ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary)) + } + .buttonStyle(.borderless) + .help(String(localized: "Filter databases")) + .accessibilityIdentifier("sidebar-database-filter") + .popover(isPresented: $showDatabaseFilter) { + DatabaseTreeFilterPopover( + connectionId: connectionId, + selectedDatabases: databaseFilterSelectionBinding + ) + } + } + private var createObjectMenu: some View { Menu { Button(String(localized: "New Table")) { coordinator?.createNewTable() } @@ -208,7 +243,8 @@ struct SidebarView: View { windowState: windowState, pendingTruncates: $pendingTruncates, pendingDeletes: $pendingDeletes, - coordinator: coordinator + coordinator: coordinator, + sidebarState: sidebarState ) } diff --git a/TableProTests/Core/Services/Query/DatabaseTreeVisibilityTests.swift b/TableProTests/Core/Services/Query/DatabaseTreeVisibilityTests.swift new file mode 100644 index 000000000..0b26d1516 --- /dev/null +++ b/TableProTests/Core/Services/Query/DatabaseTreeVisibilityTests.swift @@ -0,0 +1,43 @@ +@testable import TablePro +import Testing + +@Suite("DatabaseTreeVisibility") +struct DatabaseTreeVisibilityTests { + private let databases: [DatabaseMetadata] = [ + .minimal(name: "analytics"), + .minimal(name: "billing"), + .minimal(name: "legacy_2019"), + .minimal(name: "mysql", isSystem: true), + .minimal(name: "information_schema", isSystem: true) + ] + + @Test("Empty selection shows all non-system databases") + func emptyShowsAll() { + let visible = DatabaseTreeVisibility.visible(databases: databases, selected: []) + #expect(visible.map(\.name) == ["analytics", "billing", "legacy_2019"]) + } + + @Test("Non-empty selection shows only the selected non-system databases") + func selectionShowsSubset() { + let visible = DatabaseTreeVisibility.visible(databases: databases, selected: ["billing", "legacy_2019"]) + #expect(visible.map(\.name) == ["billing", "legacy_2019"]) + } + + @Test("System databases are never shown even when selected") + func systemNeverShown() { + let visible = DatabaseTreeVisibility.visible(databases: databases, selected: ["mysql", "analytics"]) + #expect(visible.map(\.name) == ["analytics"]) + } + + @Test("Selecting a database that no longer exists yields an empty result") + func staleSelectionEmpty() { + let visible = DatabaseTreeVisibility.visible(databases: databases, selected: ["dropped_db"]) + #expect(visible.isEmpty) + } + + @Test("isFiltering reflects whether a selection is active") + func isFiltering() { + #expect(DatabaseTreeVisibility.isFiltering(selected: []) == false) + #expect(DatabaseTreeVisibility.isFiltering(selected: ["analytics"])) + } +} diff --git a/TableProTests/Core/Storage/DatabaseTreeFilterStorageTests.swift b/TableProTests/Core/Storage/DatabaseTreeFilterStorageTests.swift new file mode 100644 index 000000000..705b9b19e --- /dev/null +++ b/TableProTests/Core/Storage/DatabaseTreeFilterStorageTests.swift @@ -0,0 +1,68 @@ +import Foundation +@testable import TablePro +import Testing + +@MainActor +@Suite("DatabaseTreeFilterStorage") +struct DatabaseTreeFilterStorageTests { + private func makeStorage() throws -> DatabaseTreeFilterStorage { + let suite = "DatabaseTreeFilterStorageTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + return DatabaseTreeFilterStorage(defaults: defaults) + } + + @Test("Defaults to an empty selection") + func defaultsEmpty() throws { + let storage = try makeStorage() + #expect(storage.selectedDatabases(connectionId: UUID()).isEmpty) + } + + @Test("Selected databases round-trip") + func selectedRoundTrip() throws { + let storage = try makeStorage() + let connId = UUID() + storage.setSelectedDatabases(Set(["db1", "db2"]), connectionId: connId) + #expect(storage.selectedDatabases(connectionId: connId) == Set(["db1", "db2"])) + } + + @Test("Setting an empty selection clears the stored value") + func emptySelectionClears() throws { + let storage = try makeStorage() + let connId = UUID() + storage.setSelectedDatabases(Set(["db1"]), connectionId: connId) + storage.setSelectedDatabases([], connectionId: connId) + #expect(storage.selectedDatabases(connectionId: connId).isEmpty) + } + + @Test("Selection is isolated per connection") + func perConnectionIsolation() throws { + let storage = try makeStorage() + let a = UUID() + let b = UUID() + storage.setSelectedDatabases(Set(["x"]), connectionId: a) + #expect(storage.selectedDatabases(connectionId: b).isEmpty) + #expect(storage.selectedDatabases(connectionId: a) == Set(["x"])) + } + + @Test("Remove filter clears the selection") + func removeClears() throws { + let storage = try makeStorage() + let connId = UUID() + storage.setSelectedDatabases(Set(["db1"]), connectionId: connId) + storage.removeFilter(for: connId) + #expect(storage.selectedDatabases(connectionId: connId).isEmpty) + } + + @Test("Remove filters batch clears across connections") + func removeBatchClears() throws { + let storage = try makeStorage() + let a = UUID() + let b = UUID() + storage.setSelectedDatabases(Set(["db1"]), connectionId: a) + storage.setSelectedDatabases(Set(["db2"]), connectionId: b) + storage.removeFilters(for: Set([a, b])) + #expect(storage.selectedDatabases(connectionId: a).isEmpty) + #expect(storage.selectedDatabases(connectionId: b).isEmpty) + } +} diff --git a/docs/databases/overview.mdx b/docs/databases/overview.mdx index cde26ce68..1da0322ec 100644 --- a/docs/databases/overview.mdx +++ b/docs/databases/overview.mdx @@ -360,6 +360,10 @@ PostgreSQL and Redshift need an initial database. Connect to `postgres` (Redshif With a restricted user, the switcher only shows databases that user has been granted access to. +### Filtering the database tree + +When the sidebar is in tree layout, a server with many databases can get noisy. Click the filter button in the sidebar footer and check the databases you want to see. The tree then shows only those; with nothing checked, it shows all. A search box helps with long lists, and the footer shows how many of the total are visible. The choice is saved per connection. If a filtered database is later dropped, the tree shows a **Show All** button to clear the filter. + {/* Screenshot: Database switcher */}