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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Each filter row has a checkbox to turn it on or off and an Apply button to filter by just that row. The main Apply runs every active filter, and disabled filters stay in the panel for later. (#1561)
- Importing connections from other apps now detects duplicates by host, port, database, and username, and lets you replace, add a copy, or skip each one before import.

### Changed
Expand Down
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,8 +172,9 @@ Missing a case produces a wrong "{Language} Query" title on the first frame.
| User preferences | UserDefaults | `AppSettingsStorage` / `AppSettingsManager` |
| Query history | SQLite FTS5 | `QueryHistoryStorage` |
| Tab state | JSON persistence | `TabPersistenceService` / `TabStateStorage` |
| Filter presets | UserDefaults | `FilterSettingsStorage` |
| Per-table filters | UserDefaults | `FilterSettingsStorage` (saves `appliedFilters` only) |
| Filter defaults | UserDefaults | `FilterSettingsStorage` (default column/operator, panel state) |
| 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) |

### Logging & Debugging
Expand Down
63 changes: 18 additions & 45 deletions TablePro/Core/Coordinators/FilterCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,6 @@ final class FilterCoordinator {
}

newFilter.filterOperator = settings.defaultOperator.toFilterOperator()
newFilter.isSelected = true

mutateSelectedTabFilterState { state in
state.filters.append(newFilter)
Expand All @@ -166,7 +165,6 @@ final class FilterCoordinator {
var newFilter = TableFilter()
newFilter.columnName = columnName
newFilter.filterOperator = settings.defaultOperator.toFilterOperator()
newFilter.isSelected = true

mutateSelectedTabFilterState { state in
state.filters.append(newFilter)
Expand All @@ -179,7 +177,7 @@ final class FilterCoordinator {
func setFKFilter(_ filter: TableFilter) {
mutateSelectedTabFilterState { state in
state.filters = [filter]
state.appliedFilters = [filter]
state.commit = .all
state.isVisible = true
state.filterLogicMode = .and
}
Expand All @@ -192,7 +190,6 @@ final class FilterCoordinator {
filterOperator: filter.filterOperator,
value: filter.value,
secondValue: filter.secondValue,
isSelected: true,
isEnabled: filter.isEnabled,
rawSQL: filter.rawSQL
)
Expand All @@ -208,7 +205,9 @@ final class FilterCoordinator {
func removeFilter(_ filter: TableFilter) {
mutateSelectedTabFilterState { state in
state.filters.removeAll { $0.id == filter.id }
state.appliedFilters.removeAll { $0.id == filter.id }
if case .solo(let id) = state.commit, id == filter.id {
state.commit = nil
}
}
}

Expand Down Expand Up @@ -279,28 +278,29 @@ final class FilterCoordinator {
guard filter.isValid else { return }
mutateSelectedTabFilterState { state in
state.filters = [filter]
state.appliedFilters = [filter]
state.commit = .all
state.isVisible = true
}
}

func applySelectedFilters() {
func applyAllFilters() {
mutateSelectedTabFilterState { state in
state.appliedFilters = state.filters.filter { $0.isSelected && $0.isValid }
state.commit = .all
}
saveLastFiltersForActiveTable()
}

func applyAllFilters() {
func applySoloFilter(_ filter: TableFilter) {
guard filter.isValid else { return }
mutateSelectedTabFilterState { state in
state.appliedFilters = state.filters.filter { $0.isEnabled && $0.isValid }
state.commit = .solo(filter.id)
}
saveLastFiltersForActiveTable()
Comment on lines +296 to 298

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve solo commits when saving last filters

When the last action is a per-row Apply, this saves through saveLastFiltersForActiveTable(), which persists tab.filterState.filters.filter(\.isValid) without the .solo(filter.id) commit. On a later table restore, restoreLastFilters rebuilds the state with commit = .all, so a table that was last queried by only one soloed row reopens/apply-rebuilds with every enabled valid row instead. Persist the commit separately or save a last-applied snapshot for solo restores while keeping the full draft panel rows elsewhere.

Useful? React with 👍 / 👎.

}

func clearAppliedFilters() {
mutateSelectedTabFilterState { state in
state.appliedFilters = []
state.commit = nil
}
}

Expand Down Expand Up @@ -330,31 +330,13 @@ final class FilterCoordinator {
}
}

// MARK: - Selection

func selectAllFilters(_ selected: Bool) {
mutateSelectedTabFilterState { state in
for index in 0..<state.filters.count {
state.filters[index].isSelected = selected
}
}
}

func toggleFilterSelection(_ filter: TableFilter) {
mutateSelectedTabFilterState { state in
if let index = state.filters.firstIndex(where: { $0.id == filter.id }) {
state.filters[index].isSelected.toggle()
}
}
}

// MARK: - Persistence

func saveLastFiltersForActiveTable() {
guard let tab = parent.tabManager.selectedTab,
let tableName = tab.tableContext.tableName else { return }
FilterSettingsStorage.shared.saveLastFilters(
tab.filterState.appliedFilters,
tab.filterState.filters.filter(\.isValid),
for: tableName,
Comment on lines 338 to 340

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep disabled filters saved when switching tabs

This path saves all valid rows so disabled filters can be restored, but switching away from the tab still calls FilterSettingsStorage.saveLastFilters(tabManager.tabs[oldIndex].filterState.appliedFilters, ...) in MainContentCoordinator+TabSwitch.swift, which resolves .all to enabled filters only. In the common flow of disabling a valid row, clicking Apply, and then switching tabs, that tab-switch save overwrites the file written here and drops the disabled row before the table is reopened. Update the tab-switch persistence to use the same full valid filter list.

Useful? React with 👍 / 👎.

connectionId: parent.connectionId,
databaseName: tab.tableContext.databaseName,
Expand All @@ -365,7 +347,7 @@ final class FilterCoordinator {
func saveLastFilters(for tableName: String) {
guard let tab = parent.tabManager.selectedTab else { return }
FilterSettingsStorage.shared.saveLastFilters(
tab.filterState.appliedFilters,
tab.filterState.filters.filter(\.isValid),
for: tableName,
connectionId: parent.connectionId,
databaseName: tab.tableContext.databaseName,
Expand Down Expand Up @@ -412,15 +394,15 @@ final class FilterCoordinator {
switch panelState {
case .alwaysHide:
state.filters = []
state.appliedFilters = []
state.commit = nil
state.isVisible = false
case .alwaysShow:
state.filters = saved
state.appliedFilters = saved
state.commit = .all
state.isVisible = true
case .restoreLast:
state.filters = saved
state.appliedFilters = saved
state.commit = .all
state.isVisible = !saved.isEmpty
}
return state
Expand All @@ -429,7 +411,7 @@ final class FilterCoordinator {
func clearFilterState() {
mutateSelectedTabFilterState { state in
state.filters = []
state.appliedFilters = []
state.commit = nil
}
}

Expand Down Expand Up @@ -475,16 +457,7 @@ final class FilterCoordinator {
}

private func filtersForPreview(in state: TabFilterState) -> [TableFilter] {
var valid: [TableFilter] = []
var selectedValid: [TableFilter] = []
for filter in state.filters where filter.isEnabled && filter.isValid {
valid.append(filter)
if filter.isSelected { selectedValid.append(filter) }
}
if selectedValid.count == valid.count || selectedValid.isEmpty {
return valid
}
return selectedValid
state.filters.filter { $0.isEnabled && $0.isValid }
}

// MARK: - Private
Expand Down
31 changes: 20 additions & 11 deletions TablePro/Models/Database/TableFilter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,13 +94,12 @@ enum FilterOperator: String, CaseIterable, Identifiable, Codable {
/// Represents a single table filter condition
struct TableFilter: Identifiable, Equatable, Hashable, Codable {
let id: UUID
var columnName: String // Column to filter on, or "__RAW__" for raw SQL
var columnName: String
var filterOperator: FilterOperator
var value: String
var secondValue: String? // For BETWEEN operator
var isSelected: Bool // For multi-select apply
var isEnabled: Bool // Whether filter is active
var rawSQL: String? // For raw SQL mode
var secondValue: String?
var isEnabled: Bool
var rawSQL: String?

/// Special column name for raw SQL mode
static let rawSQLColumn = "__RAW__"
Expand All @@ -111,7 +110,6 @@ struct TableFilter: Identifiable, Equatable, Hashable, Codable {
filterOperator: FilterOperator = .equal,
value: String = "",
secondValue: String? = nil,
isSelected: Bool = false,
isEnabled: Bool = true,
rawSQL: String? = nil
) {
Expand All @@ -120,7 +118,6 @@ struct TableFilter: Identifiable, Equatable, Hashable, Codable {
self.filterOperator = filterOperator
self.value = value
self.secondValue = secondValue
self.isSelected = isSelected
self.isEnabled = isEnabled
self.rawSQL = rawSQL
}
Expand Down Expand Up @@ -187,19 +184,31 @@ extension TableFilter {
/// Stores per-tab filter state (preserves filters when switching tabs)
struct TabFilterState: Equatable, Hashable, Codable {
var filters: [TableFilter]
var appliedFilters: [TableFilter]
var commit: FilterCommit?
var isVisible: Bool
var filterLogicMode: FilterLogicMode

init(isVisible: Bool = false) {
self.filters = []
self.appliedFilters = []
self.commit = nil
self.isVisible = isVisible
self.filterLogicMode = .and
}

var hasChanges: Bool {
!filters.isEmpty || !appliedFilters.isEmpty
var appliedFilters: [TableFilter] {
guard let commit else { return [] }
return Self.resolve(commit, in: filters)
Comment on lines +198 to +200

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the applied filter snapshot until Apply

Because appliedFilters is now derived from the editable filters array, editing an already-applied row changes what the app considers applied before the user clicks Apply. For example, after applying name = 'Alice', changing the value to Bob without applying leaves the displayed query at Alice, but any later rebuild path that reads tab.filterState.appliedFilters (pagination/sort/refresh via rebuildTableQuery) will silently query Bob. The Apply button no longer represents the boundary between draft and applied filters, so keep a snapshot of applied values or otherwise separate draft edits from committed filters.

Useful? React with 👍 / 👎.

}

static func resolve(_ commit: FilterCommit, in filters: [TableFilter]) -> [TableFilter] {
switch commit {
case .all:
return filters.filter { $0.isEnabled && $0.isValid }
case .solo(let id):
guard var match = filters.first(where: { $0.id == id }), match.isValid else { return [] }
match.isEnabled = true
return [match]
}
}

var hasAppliedFilters: Bool {
Expand Down
9 changes: 7 additions & 2 deletions TablePro/Models/UI/FilterState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,15 @@ enum FilterLogicMode: String, Codable {
}
}

enum FilterCommit: Codable, Equatable, Hashable {
case all
case solo(UUID)
}

extension TabFilterState {
init(filters: [TableFilter], appliedFilters: [TableFilter], isVisible: Bool, filterLogicMode: FilterLogicMode) {
init(filters: [TableFilter], commit: FilterCommit?, isVisible: Bool, filterLogicMode: FilterLogicMode) {
self.filters = filters
self.appliedFilters = appliedFilters
self.commit = commit
self.isVisible = isVisible
self.filterLogicMode = filterLogicMode
}
Expand Down
Loading
Loading