From b6f7d492c750808f5096dfc0c44afaa020d49371 Mon Sep 17 00:00:00 2001 From: xuhengyu Date: Sun, 14 Jun 2026 17:46:44 +0800 Subject: [PATCH 1/3] feat(sidebar): add database filter to the tree sidebar --- CHANGELOG.md | 4 + CLAUDE.md | 1 + TablePro/Core/Storage/ConnectionStorage.swift | 2 + .../Storage/DatabaseTreeFilterStorage.swift | 51 +++++++++++ .../Sidebar/DatabaseTreeFilterPopover.swift | 90 +++++++++++++++++++ TablePro/Views/Sidebar/DatabaseTreeView.swift | 7 +- TablePro/Views/Sidebar/SidebarView.swift | 39 +++++++- .../DatabaseTreeFilterStorageTests.swift | 78 ++++++++++++++++ 8 files changed, 270 insertions(+), 2 deletions(-) create mode 100644 TablePro/Core/Storage/DatabaseTreeFilterStorage.swift create mode 100644 TablePro/Views/Sidebar/DatabaseTreeFilterPopover.swift create mode 100644 TableProTests/Core/Storage/DatabaseTreeFilterStorageTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 17fb52f4f..2a8d5c742 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 select, toggled from a filter button at the bottom of the sidebar. + ## [0.51.0] - 2026-06-13 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index ffb0822de..9f018b6e4 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; enable toggle + visible database set; device-local) | ### Logging & Debugging 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..0529e8f08 --- /dev/null +++ b/TablePro/Core/Storage/DatabaseTreeFilterStorage.swift @@ -0,0 +1,51 @@ +import Foundation + +/// Device-local, per-connection filter for the tree sidebar's database list. +/// Holds an enable toggle and the set of database names shown when enabled. +@MainActor +final class DatabaseTreeFilterStorage { + static let shared = DatabaseTreeFilterStorage() + + private let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + private func enabledKey(connectionId: UUID) -> String { + "com.TablePro.treeDatabaseFilter.\(connectionId.uuidString).enabled" + } + + private func databasesKey(connectionId: UUID) -> String { + "com.TablePro.treeDatabaseFilter.\(connectionId.uuidString).selected" + } + + func isEnabled(connectionId: UUID) -> Bool { + defaults.bool(forKey: enabledKey(connectionId: connectionId)) + } + + func setEnabled(_ enabled: Bool, connectionId: UUID) { + defaults.set(enabled, forKey: enabledKey(connectionId: connectionId)) + } + + 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) { + guard let data = try? JSONEncoder().encode(Array(databases).sorted()) else { return } + defaults.set(data, forKey: databasesKey(connectionId: connectionId)) + } + + func removeFilter(for connectionId: UUID) { + defaults.removeObject(forKey: enabledKey(connectionId: connectionId)) + defaults.removeObject(forKey: databasesKey(connectionId: connectionId)) + } + + func removeFilters(for connectionIds: Set) { + for id in connectionIds { removeFilter(for: id) } + } +} diff --git a/TablePro/Views/Sidebar/DatabaseTreeFilterPopover.swift b/TablePro/Views/Sidebar/DatabaseTreeFilterPopover.swift new file mode 100644 index 000000000..5ab9a7fc0 --- /dev/null +++ b/TablePro/Views/Sidebar/DatabaseTreeFilterPopover.swift @@ -0,0 +1,90 @@ +// +// DatabaseTreeFilterPopover.swift +// TablePro +// + +import SwiftUI + +struct DatabaseTreeFilterPopover: View { + let connectionId: UUID + + @Binding var enabled: Bool + @Binding var selectedDatabases: Set + + @Bindable private var treeService = DatabaseTreeMetadataService.shared + + private var selectableDatabases: [DatabaseMetadata] { + treeService.databases(for: connectionId) + .filter { !$0.isSystemDatabase } + } + + var body: some View { + VStack(spacing: 0) { + Toggle(String(localized: "Only show selected databases"), isOn: $enabled) + .padding(.horizontal, 12) + .padding(.vertical, 10) + + Divider() + + databaseList + + Divider() + + HStack(spacing: 12) { + Button(String(localized: "Select All")) { + selectedDatabases = Set(selectableDatabases.map(\.name)) + } + .buttonStyle(.borderless) + .disabled(selectableDatabases.isEmpty) + + Button(String(localized: "Clear")) { + selectedDatabases = [] + } + .buttonStyle(.borderless) + .disabled(selectedDatabases.isEmpty) + + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + .frame(minWidth: 280) + } + + @ViewBuilder + private var databaseList: 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: 120) + .padding(.vertical, 8) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 1) { + ForEach(selectableDatabases, id: \.name) { db in + Toggle(db.name, isOn: databaseBinding(for: db.name)) + .toggleStyle(.checkbox) + .padding(.horizontal, 12) + .padding(.vertical, 3) + } + } + .padding(.vertical, 6) + } + .frame(maxHeight: 260) + .disabled(!enabled) + } + } + + 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..d8cb49bef 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeView.swift @@ -53,6 +53,8 @@ struct DatabaseTreeView: View { @Binding var pendingTruncates: Set @Binding var pendingDeletes: Set let coordinator: MainContentCoordinator? + let databaseFilterEnabled: Bool + let selectedDatabases: Set @State private var localSelection: Set = [] @State private var searchText: String = "" @@ -436,7 +438,10 @@ struct DatabaseTreeView: View { private var visibleDatabases: [DatabaseMetadata] { let nonSystem = databases.filter { !$0.isSystemDatabase } - let matched = searchText.isEmpty ? nonSystem : nonSystem.filter { databaseMatchesSearch($0) } + let allowed = databaseFilterEnabled + ? nonSystem.filter { selectedDatabases.contains($0.name) } + : nonSystem + let matched = searchText.isEmpty ? allowed : allowed.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..3a08807ca 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -11,6 +11,9 @@ import TableProPluginKit struct SidebarView: View { @State private var viewModel: SidebarViewModel @State private var favoriteTables: Set = [] + @State private var showDatabaseFilter: Bool = false + @State private var databaseFilterEnabled: Bool = false + @State private var databaseFilterSelected: Set = [] private var schemaService: SchemaService { SchemaService.shared } @@ -120,12 +123,20 @@ struct SidebarView: View { .onChange(of: windowState.searchText) { _, newValue in viewModel.searchText = newValue } + .onChange(of: databaseFilterEnabled) { _, newValue in + DatabaseTreeFilterStorage.shared.setEnabled(newValue, connectionId: connectionId) + } + .onChange(of: databaseFilterSelected) { _, newValue in + DatabaseTreeFilterStorage.shared.setSelectedDatabases(newValue, connectionId: connectionId) + } .onAppear { coordinator?.sidebarViewModel = viewModel if let driver = DatabaseManager.shared.driver(for: connectionId), coordinator?.toolbarState.databaseVersion == nil { coordinator?.toolbarState.databaseVersion = driver.serverVersion } + databaseFilterEnabled = DatabaseTreeFilterStorage.shared.isEnabled(connectionId: connectionId) + databaseFilterSelected = DatabaseTreeFilterStorage.shared.selectedDatabases(connectionId: connectionId) } .sheet(isPresented: $viewModel.showOperationDialog) { if let operationType = viewModel.pendingOperationType { @@ -165,6 +176,9 @@ struct SidebarView: View { Divider() HStack(spacing: 8) { createObjectMenu + if usesDatabaseTree { + databaseFilterButton + } Spacer() if supportsSchemaFooter { SchemaPickerControl( @@ -179,6 +193,27 @@ struct SidebarView: View { } } + private var databaseFilterButton: some View { + Button { + showDatabaseFilter = true + } label: { + Image(systemName: databaseFilterEnabled + ? "line.3.horizontal.decrease.circle.fill" + : "line.3.horizontal.decrease.circle") + .foregroundStyle(databaseFilterEnabled ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary)) + } + .buttonStyle(.borderless) + .help(String(localized: "Filter databases")) + .accessibilityIdentifier("sidebar-database-filter") + .popover(isPresented: $showDatabaseFilter) { + DatabaseTreeFilterPopover( + connectionId: connectionId, + enabled: $databaseFilterEnabled, + selectedDatabases: $databaseFilterSelected + ) + } + } + private var createObjectMenu: some View { Menu { Button(String(localized: "New Table")) { coordinator?.createNewTable() } @@ -208,7 +243,9 @@ struct SidebarView: View { windowState: windowState, pendingTruncates: $pendingTruncates, pendingDeletes: $pendingDeletes, - coordinator: coordinator + coordinator: coordinator, + databaseFilterEnabled: databaseFilterEnabled, + selectedDatabases: databaseFilterSelected ) } diff --git a/TableProTests/Core/Storage/DatabaseTreeFilterStorageTests.swift b/TableProTests/Core/Storage/DatabaseTreeFilterStorageTests.swift new file mode 100644 index 000000000..cb11e7710 --- /dev/null +++ b/TableProTests/Core/Storage/DatabaseTreeFilterStorageTests.swift @@ -0,0 +1,78 @@ +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 disabled with empty selection") + func defaultsDisabledEmpty() throws { + let storage = try makeStorage() + let connId = UUID() + #expect(storage.isEnabled(connectionId: connId) == false) + #expect(storage.selectedDatabases(connectionId: connId).isEmpty) + } + + @Test("Enable toggle persists") + func enablePersists() throws { + let storage = try makeStorage() + let connId = UUID() + storage.setEnabled(true, connectionId: connId) + #expect(storage.isEnabled(connectionId: connId)) + storage.setEnabled(false, connectionId: connId) + #expect(!storage.isEnabled(connectionId: connId)) + } + + @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("State is isolated per connection") + func perConnectionIsolation() throws { + let storage = try makeStorage() + let a = UUID() + let b = UUID() + storage.setEnabled(true, connectionId: a) + storage.setSelectedDatabases(Set(["x"]), connectionId: a) + #expect(!storage.isEnabled(connectionId: b)) + #expect(storage.selectedDatabases(connectionId: b).isEmpty) + } + + @Test("Remove filter clears both fields") + func removeClearsBoth() throws { + let storage = try makeStorage() + let connId = UUID() + storage.setEnabled(true, connectionId: connId) + storage.setSelectedDatabases(Set(["db1"]), connectionId: connId) + storage.removeFilter(for: connId) + #expect(!storage.isEnabled(connectionId: 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.setEnabled(true, connectionId: a) + storage.setSelectedDatabases(Set(["db1"]), connectionId: a) + storage.setEnabled(true, connectionId: b) + storage.setSelectedDatabases(Set(["db2"]), connectionId: b) + storage.removeFilters(for: Set([a, b])) + #expect(!storage.isEnabled(connectionId: a)) + #expect(!storage.isEnabled(connectionId: b)) + #expect(storage.selectedDatabases(connectionId: a).isEmpty) + #expect(storage.selectedDatabases(connectionId: b).isEmpty) + } +} From 94372a7b21ddc50dcf7b9cbe72a8ad80408d4cfd Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 14 Jun 2026 23:18:23 +0700 Subject: [PATCH 2/3] refactor(sidebar): move tree database filter into SharedSidebarState --- CHANGELOG.md | 2 +- CLAUDE.md | 2 +- .../Query/DatabaseTreeVisibility.swift | 13 +++ .../Storage/DatabaseTreeFilterStorage.swift | 24 ++-- TablePro/Models/UI/SharedSidebarState.swift | 11 ++ .../Sidebar/DatabaseTreeFilterPopover.swift | 109 ++++++++++++------ TablePro/Views/Sidebar/DatabaseTreeView.swift | 33 ++++-- TablePro/Views/Sidebar/SidebarView.swift | 31 +++-- .../Query/DatabaseTreeVisibilityTests.swift | 43 +++++++ .../DatabaseTreeFilterStorageTests.swift | 42 +++---- docs/databases/overview.mdx | 4 + 11 files changed, 208 insertions(+), 106 deletions(-) create mode 100644 TablePro/Core/Services/Query/DatabaseTreeVisibility.swift create mode 100644 TableProTests/Core/Services/Query/DatabaseTreeVisibilityTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a8d5c742..39fb14398 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- The tree sidebar can show only the databases you select, toggled from a filter button at the bottom of the sidebar. +- 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 diff --git a/CLAUDE.md b/CLAUDE.md index 9f018b6e4..d452b76a6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -182,7 +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; enable toggle + visible database set; device-local) | +| 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/DatabaseTreeFilterStorage.swift b/TablePro/Core/Storage/DatabaseTreeFilterStorage.swift index 0529e8f08..90610afaa 100644 --- a/TablePro/Core/Storage/DatabaseTreeFilterStorage.swift +++ b/TablePro/Core/Storage/DatabaseTreeFilterStorage.swift @@ -1,7 +1,5 @@ import Foundation -/// Device-local, per-connection filter for the tree sidebar's database list. -/// Holds an enable toggle and the set of database names shown when enabled. @MainActor final class DatabaseTreeFilterStorage { static let shared = DatabaseTreeFilterStorage() @@ -12,22 +10,10 @@ final class DatabaseTreeFilterStorage { self.defaults = defaults } - private func enabledKey(connectionId: UUID) -> String { - "com.TablePro.treeDatabaseFilter.\(connectionId.uuidString).enabled" - } - private func databasesKey(connectionId: UUID) -> String { "com.TablePro.treeDatabaseFilter.\(connectionId.uuidString).selected" } - func isEnabled(connectionId: UUID) -> Bool { - defaults.bool(forKey: enabledKey(connectionId: connectionId)) - } - - func setEnabled(_ enabled: Bool, connectionId: UUID) { - defaults.set(enabled, forKey: enabledKey(connectionId: connectionId)) - } - func selectedDatabases(connectionId: UUID) -> Set { guard let data = defaults.data(forKey: databasesKey(connectionId: connectionId)), let names = try? JSONDecoder().decode([String].self, from: data) @@ -36,12 +22,16 @@ final class DatabaseTreeFilterStorage { } func setSelectedDatabases(_ databases: Set, connectionId: UUID) { - guard let data = try? JSONEncoder().encode(Array(databases).sorted()) else { return } - defaults.set(data, forKey: databasesKey(connectionId: connectionId)) + 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: enabledKey(connectionId: connectionId)) defaults.removeObject(forKey: databasesKey(connectionId: connectionId)) } 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 index 5ab9a7fc0..330d7e7eb 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeFilterPopover.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeFilterPopover.swift @@ -8,74 +8,107 @@ import SwiftUI struct DatabaseTreeFilterPopover: View { let connectionId: UUID - @Binding var enabled: Bool @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 { $0.name.localizedCaseInsensitiveContains(searchText) } + } + + 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) { - Toggle(String(localized: "Only show selected databases"), isOn: $enabled) - .padding(.horizontal, 12) - .padding(.vertical, 10) + searchField Divider() - databaseList + content Divider() - HStack(spacing: 12) { - Button(String(localized: "Select All")) { - selectedDatabases = Set(selectableDatabases.map(\.name)) - } - .buttonStyle(.borderless) - .disabled(selectableDatabases.isEmpty) - - Button(String(localized: "Clear")) { - selectedDatabases = [] - } - .buttonStyle(.borderless) - .disabled(selectedDatabases.isEmpty) - - Spacer() - } - .padding(.horizontal, 12) - .padding(.vertical, 8) + footer } - .frame(minWidth: 280) + .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 databaseList: some View { + 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: 120) - .padding(.vertical, 8) + .frame(maxWidth: .infinity, minHeight: 160) + } else if matchingDatabases.isEmpty { + ContentUnavailableView.search(text: searchText) + .frame(maxWidth: .infinity, minHeight: 160) } else { - ScrollView { - VStack(alignment: .leading, spacing: 1) { - ForEach(selectableDatabases, id: \.name) { db in - Toggle(db.name, isOn: databaseBinding(for: db.name)) - .toggleStyle(.checkbox) - .padding(.horizontal, 12) - .padding(.vertical, 3) - } - } - .padding(.vertical, 6) + 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) } - .frame(maxHeight: 260) - .disabled(!enabled) } + .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 { diff --git a/TablePro/Views/Sidebar/DatabaseTreeView.swift b/TablePro/Views/Sidebar/DatabaseTreeView.swift index d8cb49bef..d0f55f462 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeView.swift @@ -53,8 +53,7 @@ struct DatabaseTreeView: View { @Binding var pendingTruncates: Set @Binding var pendingDeletes: Set let coordinator: MainContentCoordinator? - let databaseFilterEnabled: Bool - let selectedDatabases: Set + let sidebarState: SharedSidebarState @State private var localSelection: Set = [] @State private var searchText: String = "" @@ -106,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: @@ -319,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() @@ -436,12 +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 allowed = databaseFilterEnabled - ? nonSystem.filter { selectedDatabases.contains($0.name) } - : nonSystem - let matched = searchText.isEmpty ? allowed : allowed.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 3a08807ca..eb374eed8 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -12,8 +12,6 @@ struct SidebarView: View { @State private var viewModel: SidebarViewModel @State private var favoriteTables: Set = [] @State private var showDatabaseFilter: Bool = false - @State private var databaseFilterEnabled: Bool = false - @State private var databaseFilterSelected: Set = [] private var schemaService: SchemaService { SchemaService.shared } @@ -123,20 +121,12 @@ struct SidebarView: View { .onChange(of: windowState.searchText) { _, newValue in viewModel.searchText = newValue } - .onChange(of: databaseFilterEnabled) { _, newValue in - DatabaseTreeFilterStorage.shared.setEnabled(newValue, connectionId: connectionId) - } - .onChange(of: databaseFilterSelected) { _, newValue in - DatabaseTreeFilterStorage.shared.setSelectedDatabases(newValue, connectionId: connectionId) - } .onAppear { coordinator?.sidebarViewModel = viewModel if let driver = DatabaseManager.shared.driver(for: connectionId), coordinator?.toolbarState.databaseVersion == nil { coordinator?.toolbarState.databaseVersion = driver.serverVersion } - databaseFilterEnabled = DatabaseTreeFilterStorage.shared.isEnabled(connectionId: connectionId) - databaseFilterSelected = DatabaseTreeFilterStorage.shared.selectedDatabases(connectionId: connectionId) } .sheet(isPresented: $viewModel.showOperationDialog) { if let operationType = viewModel.pendingOperationType { @@ -193,14 +183,25 @@ 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: databaseFilterEnabled + Image(systemName: isDatabaseFilterActive ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle") - .foregroundStyle(databaseFilterEnabled ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary)) + .foregroundStyle(isDatabaseFilterActive ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary)) } .buttonStyle(.borderless) .help(String(localized: "Filter databases")) @@ -208,8 +209,7 @@ struct SidebarView: View { .popover(isPresented: $showDatabaseFilter) { DatabaseTreeFilterPopover( connectionId: connectionId, - enabled: $databaseFilterEnabled, - selectedDatabases: $databaseFilterSelected + selectedDatabases: databaseFilterSelectionBinding ) } } @@ -244,8 +244,7 @@ struct SidebarView: View { pendingTruncates: $pendingTruncates, pendingDeletes: $pendingDeletes, coordinator: coordinator, - databaseFilterEnabled: databaseFilterEnabled, - selectedDatabases: databaseFilterSelected + 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 index cb11e7710..705b9b19e 100644 --- a/TableProTests/Core/Storage/DatabaseTreeFilterStorageTests.swift +++ b/TableProTests/Core/Storage/DatabaseTreeFilterStorageTests.swift @@ -12,22 +12,10 @@ struct DatabaseTreeFilterStorageTests { return DatabaseTreeFilterStorage(defaults: defaults) } - @Test("Defaults to disabled with empty selection") - func defaultsDisabledEmpty() throws { + @Test("Defaults to an empty selection") + func defaultsEmpty() throws { let storage = try makeStorage() - let connId = UUID() - #expect(storage.isEnabled(connectionId: connId) == false) - #expect(storage.selectedDatabases(connectionId: connId).isEmpty) - } - - @Test("Enable toggle persists") - func enablePersists() throws { - let storage = try makeStorage() - let connId = UUID() - storage.setEnabled(true, connectionId: connId) - #expect(storage.isEnabled(connectionId: connId)) - storage.setEnabled(false, connectionId: connId) - #expect(!storage.isEnabled(connectionId: connId)) + #expect(storage.selectedDatabases(connectionId: UUID()).isEmpty) } @Test("Selected databases round-trip") @@ -38,25 +26,31 @@ struct DatabaseTreeFilterStorageTests { #expect(storage.selectedDatabases(connectionId: connId) == Set(["db1", "db2"])) } - @Test("State is isolated per connection") + @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.setEnabled(true, connectionId: a) storage.setSelectedDatabases(Set(["x"]), connectionId: a) - #expect(!storage.isEnabled(connectionId: b)) #expect(storage.selectedDatabases(connectionId: b).isEmpty) + #expect(storage.selectedDatabases(connectionId: a) == Set(["x"])) } - @Test("Remove filter clears both fields") - func removeClearsBoth() throws { + @Test("Remove filter clears the selection") + func removeClears() throws { let storage = try makeStorage() let connId = UUID() - storage.setEnabled(true, connectionId: connId) storage.setSelectedDatabases(Set(["db1"]), connectionId: connId) storage.removeFilter(for: connId) - #expect(!storage.isEnabled(connectionId: connId)) #expect(storage.selectedDatabases(connectionId: connId).isEmpty) } @@ -65,13 +59,9 @@ struct DatabaseTreeFilterStorageTests { let storage = try makeStorage() let a = UUID() let b = UUID() - storage.setEnabled(true, connectionId: a) storage.setSelectedDatabases(Set(["db1"]), connectionId: a) - storage.setEnabled(true, connectionId: b) storage.setSelectedDatabases(Set(["db2"]), connectionId: b) storage.removeFilters(for: Set([a, b])) - #expect(!storage.isEnabled(connectionId: a)) - #expect(!storage.isEnabled(connectionId: 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 */} Date: Sun, 14 Jun 2026 23:26:11 +0700 Subject: [PATCH 3/3] refactor(sidebar): match popover database search to tree fuzzy matching --- TablePro/Views/Sidebar/DatabaseTreeFilterPopover.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TablePro/Views/Sidebar/DatabaseTreeFilterPopover.swift b/TablePro/Views/Sidebar/DatabaseTreeFilterPopover.swift index 330d7e7eb..8f4b863e9 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeFilterPopover.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeFilterPopover.swift @@ -22,7 +22,7 @@ struct DatabaseTreeFilterPopover: View { private var matchingDatabases: [DatabaseMetadata] { guard !searchText.isEmpty else { return selectableDatabases } - return selectableDatabases.filter { $0.name.localizedCaseInsensitiveContains(searchText) } + return selectableDatabases.filter { FuzzyMatcher.matches(query: searchText, candidate: $0.name) } } private var shownCount: Int {