Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 13 additions & 0 deletions TablePro/Core/Services/Query/DatabaseTreeVisibility.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import Foundation

enum DatabaseTreeVisibility {
static func visible(databases: [DatabaseMetadata], selected: Set<String>) -> [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<String>) -> Bool {
!selected.isEmpty
}
}
2 changes: 2 additions & 0 deletions TablePro/Core/Storage/ConnectionStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand Down
41 changes: 41 additions & 0 deletions TablePro/Core/Storage/DatabaseTreeFilterStorage.swift
Original file line number Diff line number Diff line change
@@ -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<String> {
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<String>, 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<UUID>) {
for id in connectionIds { removeFilter(for: id) }
}
}
11 changes: 11 additions & 0 deletions TablePro/Models/UI/SharedSidebarState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,15 @@ final class SharedSidebarState {
}
}

var databaseFilterSelected: Set<String> {
didSet {
DatabaseTreeFilterStorage.shared.setSelectedDatabases(
databaseFilterSelected,
connectionId: connectionId
)
}
}

static var defaultLayout: SidebarLayout {
get {
guard let raw = UserDefaults.standard.string(forKey: SidebarPersistenceKey.defaultLayout),
Expand Down Expand Up @@ -73,13 +82,15 @@ final class SharedSidebarState {
} else {
self.sidebarLayout = SharedSidebarState.defaultLayout
}
self.databaseFilterSelected = DatabaseTreeFilterStorage.shared.selectedDatabases(connectionId: connectionId)
}

/// Default init for previews and tests
init() {
self.connectionId = UUID()
self.selectedSidebarTab = .tables
self.sidebarLayout = .flat
self.databaseFilterSelected = []
}

private static var registry: [UUID: SharedSidebarState] = [:]
Expand Down
123 changes: 123 additions & 0 deletions TablePro/Views/Sidebar/DatabaseTreeFilterPopover.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
//
// DatabaseTreeFilterPopover.swift
// TablePro
//

import SwiftUI

struct DatabaseTreeFilterPopover: View {
let connectionId: UUID

@Binding var selectedDatabases: Set<String>

@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<Bool> {
Binding(
get: { selectedDatabases.contains(database) },
set: { isOn in
if isOn { selectedDatabases.insert(database) }
else { selectedDatabases.remove(database) }
}
)
}
}
28 changes: 26 additions & 2 deletions TablePro/Views/Sidebar/DatabaseTreeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ struct DatabaseTreeView: View {
@Binding var pendingTruncates: Set<String>
@Binding var pendingDeletes: Set<String>
let coordinator: MainContentCoordinator?
let sidebarState: SharedSidebarState

@State private var localSelection: Set<DatabaseTreeTableRef> = []
@State private var searchText: String = ""
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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<String>()
return matched.filter { seen.insert($0.id).inserted }
}
Expand Down
38 changes: 37 additions & 1 deletion TablePro/Views/Sidebar/SidebarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import TableProPluginKit
struct SidebarView: View {
@State private var viewModel: SidebarViewModel
@State private var favoriteTables: Set<FavoriteTablesStorage.FavoriteEntry> = []
@State private var showDatabaseFilter: Bool = false

private var schemaService: SchemaService { SchemaService.shared }

Expand Down Expand Up @@ -165,6 +166,9 @@ struct SidebarView: View {
Divider()
HStack(spacing: 8) {
createObjectMenu
if usesDatabaseTree {
databaseFilterButton
}
Spacer()
if supportsSchemaFooter {
SchemaPickerControl(
Expand All @@ -179,6 +183,37 @@ struct SidebarView: View {
}
}

private var isDatabaseFilterActive: Bool {
!sidebarState.databaseFilterSelected.isEmpty
}

private var databaseFilterSelectionBinding: Binding<Set<String>> {
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() }
Expand Down Expand Up @@ -208,7 +243,8 @@ struct SidebarView: View {
windowState: windowState,
pendingTruncates: $pendingTruncates,
pendingDeletes: $pendingDeletes,
coordinator: coordinator
coordinator: coordinator,
sidebarState: sidebarState
)
}

Expand Down
Loading
Loading