From d2f7d6f142008cccd7c92c9c4b04bd42aec0a0f3 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 23 May 2026 00:05:52 +0700 Subject: [PATCH 1/3] fix(datagrid): persist cleared filters so they don't return on reopen (#1347) --- CHANGELOG.md | 2 +- .../Core/Coordinators/FilterCoordinator.swift | 11 +++++++++++ .../Core/Storage/FilterSettingsStorageTests.swift | 15 +++++++++++++++ docs/features/filtering.mdx | 2 +- 4 files changed, 28 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31c1cf960..1c3dd0c44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Connecting to a PostgreSQL-compatible engine that doesn't implement the pg_matviews catalog (such as db9.ai) no longer fails to load tables. (#1383) - Filtering a table now updates the row count and page count in the bottom-right to match the filtered result, instead of showing the whole-table totals. -- Reopening a table now restores the filter you had applied, instead of clearing it. Filters are remembered per connection. (#1347) +- Reopening a table now restores the filter you had applied, and clearing a filter is remembered too, so a table you cleared reopens with no filter. Filters are remembered per connection. (#1347) - Quick switcher panel height now fits its results instead of leaving a large empty area below short lists. (#1349) - Importing connections from TablePlus brings over saved passwords again. A recent release looked under the wrong keychain name, so connections imported with no passwords and no warning. - Importing an SSH connection from TablePlus no longer fills in a fake private key path such as `~/.ssh/Import a private key...` when no key was selected. Empty TLS certificate paths are skipped too. diff --git a/TablePro/Core/Coordinators/FilterCoordinator.swift b/TablePro/Core/Coordinators/FilterCoordinator.swift index 5a98cecb0..bfd8b69b9 100644 --- a/TablePro/Core/Coordinators/FilterCoordinator.swift +++ b/TablePro/Core/Coordinators/FilterCoordinator.swift @@ -81,6 +81,7 @@ final class FilterCoordinator { ) parent.tabManager.mutate(at: capturedTabIndex) { $0.content.query = newQuery } + clearLastFilters(for: capturedTableName) parent.runQuery() } } @@ -343,6 +344,16 @@ final class FilterCoordinator { ) } + func clearLastFilters(for tableName: String) { + guard let tab = parent.tabManager.selectedTab else { return } + FilterSettingsStorage.shared.clearLastFilters( + for: tableName, + connectionId: parent.connectionId, + databaseName: tab.tableContext.databaseName, + schemaName: tab.tableContext.schemaName + ) + } + func restoreLastFilters(for tableName: String) { let settings = FilterSettingsStorage.shared.loadSettings() guard settings.panelState != .alwaysHide, diff --git a/TableProTests/Core/Storage/FilterSettingsStorageTests.swift b/TableProTests/Core/Storage/FilterSettingsStorageTests.swift index 888e419b0..ae7973378 100644 --- a/TableProTests/Core/Storage/FilterSettingsStorageTests.swift +++ b/TableProTests/Core/Storage/FilterSettingsStorageTests.swift @@ -150,4 +150,19 @@ struct FilterSettingsStorageTests { reader.loadLastFilters(for: "users", connectionId: connectionId, databaseName: "db", schemaName: nil) == filters ) } + + @Test("Clearing removes the stored filters so a reopen restores nothing") + func clearRemovesStoredFilters() { + let (storage, directory) = makeStorage() + defer { try? FileManager.default.removeItem(at: directory) } + let connectionId = UUID() + let filters = [TestFixtures.makeTableFilter(column: "email", value: "a@b.com")] + + storage.saveLastFilters(filters, for: "users", connectionId: connectionId, databaseName: "db", schemaName: nil) + storage.clearLastFilters(for: "users", connectionId: connectionId, databaseName: "db", schemaName: nil) + + #expect( + storage.loadLastFilters(for: "users", connectionId: connectionId, databaseName: "db", schemaName: nil).isEmpty + ) + } } diff --git a/docs/features/filtering.mdx b/docs/features/filtering.mdx index 2f5731039..17e0eefd7 100644 --- a/docs/features/filtering.mdx +++ b/docs/features/filtering.mdx @@ -9,7 +9,7 @@ Press `Cmd+F` while viewing a table to open the filter panel. Type a raw SQL WHE Each row has a column picker, operator, value field, and **+**/**−** buttons. Multiple rows combine with **AND** or **OR** (toggle in the header). Click **Apply** or press `Enter` to activate. Click **Unset** to clear all. -When you reopen a table, TablePro restores the filter you last applied to it, including after you quit and relaunch. Filters are remembered per connection. Tables with active filters open in a new tab when you click another table. +When you reopen a table, TablePro restores the filter you last applied to it, including after you quit and relaunch. If you clear the filter with **Unset**, reopening the table shows it unfiltered. Filters are remembered per connection. Tables with active filters open in a new tab when you click another table. ## Operators From f1efca28302dac1919faee3d940ecc2390fad83f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 23 May 2026 00:23:36 +0700 Subject: [PATCH 2/3] fix(datagrid): removing filter rows now reloads and persists so they don't return on reopen (#1347) --- CHANGELOG.md | 2 +- .../Core/Coordinators/FilterCoordinator.swift | 18 +++++++++++++----- TablePro/Views/Filter/FilterPanelView.swift | 10 ++-------- .../MainContentCoordinator+FilterState.swift | 4 ++++ docs/features/filtering.mdx | 2 +- 5 files changed, 21 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c3dd0c44..3ed0a9d93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Connecting to a PostgreSQL-compatible engine that doesn't implement the pg_matviews catalog (such as db9.ai) no longer fails to load tables. (#1383) - Filtering a table now updates the row count and page count in the bottom-right to match the filtered result, instead of showing the whole-table totals. -- Reopening a table now restores the filter you had applied, and clearing a filter is remembered too, so a table you cleared reopens with no filter. Filters are remembered per connection. (#1347) +- Reopening a table now restores the filter you had applied. Removing or clearing a filter takes effect right away and is remembered, so a table you unfiltered reopens with no filter. Filters are remembered per connection. (#1347) - Quick switcher panel height now fits its results instead of leaving a large empty area below short lists. (#1349) - Importing connections from TablePlus brings over saved passwords again. A recent release looked under the wrong keychain name, so connections imported with no passwords and no warning. - Importing an SSH connection from TablePlus no longer fills in a fake private key path such as `~/.ssh/Import a private key...` when no key was selected. Empty TLS certificate paths are skipped too. diff --git a/TablePro/Core/Coordinators/FilterCoordinator.swift b/TablePro/Core/Coordinators/FilterCoordinator.swift index bfd8b69b9..e82c6c86a 100644 --- a/TablePro/Core/Coordinators/FilterCoordinator.swift +++ b/TablePro/Core/Coordinators/FilterCoordinator.swift @@ -47,11 +47,7 @@ final class FilterCoordinator { ) parent.tabManager.mutate(at: capturedTabIndex) { $0.content.query = newQuery } - - if !capturedFilters.isEmpty { - saveLastFilters(for: capturedTableName) - } - + saveLastFilters(for: capturedTableName) parent.runQuery() } } @@ -216,6 +212,18 @@ final class FilterCoordinator { } } + func removeFilterAndReload(_ filter: TableFilter) { + let wasApplied = selectedTabFilterState.appliedFilters.contains { $0.id == filter.id } + removeFilter(filter) + guard wasApplied else { return } + let remaining = selectedTabFilterState.appliedFilters + if remaining.isEmpty { + clearFiltersAndReload() + } else { + applyFilters(remaining) + } + } + func updateFilter(_ filter: TableFilter) { mutateSelectedTabFilterState { state in if let index = state.filters.firstIndex(where: { $0.id == filter.id }) { diff --git a/TablePro/Views/Filter/FilterPanelView.swift b/TablePro/Views/Filter/FilterPanelView.swift index 9558b13e4..9719718bc 100644 --- a/TablePro/Views/Filter/FilterPanelView.swift +++ b/TablePro/Views/Filter/FilterPanelView.swift @@ -199,15 +199,9 @@ struct FilterPanelView: View { focusedFilterId = filterState.filters.last?.id }, onRemove: { - let hadAppliedFilters = filterState.hasAppliedFilters - coordinator.removeFilter(filter) + coordinator.removeFilterAndReload(filter) if filterState.filters.isEmpty { - if hadAppliedFilters { - coordinator.clearFilterState() - onUnset() - } else { - coordinator.closeFilterPanel() - } + coordinator.closeFilterPanel() } }, onSubmit: { applyAllValidFilters() }, diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+FilterState.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+FilterState.swift index 77c5660ee..31287e568 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+FilterState.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+FilterState.swift @@ -36,6 +36,10 @@ extension MainContentCoordinator { filterCoordinator.removeFilter(filter) } + func removeFilterAndReload(_ filter: TableFilter) { + filterCoordinator.removeFilterAndReload(filter) + } + func updateFilter(_ filter: TableFilter) { filterCoordinator.updateFilter(filter) } diff --git a/docs/features/filtering.mdx b/docs/features/filtering.mdx index 17e0eefd7..7679d39a8 100644 --- a/docs/features/filtering.mdx +++ b/docs/features/filtering.mdx @@ -9,7 +9,7 @@ Press `Cmd+F` while viewing a table to open the filter panel. Type a raw SQL WHE Each row has a column picker, operator, value field, and **+**/**−** buttons. Multiple rows combine with **AND** or **OR** (toggle in the header). Click **Apply** or press `Enter` to activate. Click **Unset** to clear all. -When you reopen a table, TablePro restores the filter you last applied to it, including after you quit and relaunch. If you clear the filter with **Unset**, reopening the table shows it unfiltered. Filters are remembered per connection. Tables with active filters open in a new tab when you click another table. +When you reopen a table, TablePro restores the filter you last applied to it, including after you quit and relaunch. If you clear the filter with **Unset** or remove its conditions with **−**, reopening the table shows it unfiltered. Filters are remembered per connection. Tables with active filters open in a new tab when you click another table. ## Operators From dd20a80f31d0bbbd6490f6a30caed633f29e7c38 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 23 May 2026 00:28:53 +0700 Subject: [PATCH 3/3] refactor(datagrid): extract removeFilterOutcome as a tested pure function (#1347) --- .../Core/Coordinators/FilterCoordinator.swift | 29 +++++++++++++++---- .../Views/Main/FilterRestoreTests.swift | 22 ++++++++++++++ 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/TablePro/Core/Coordinators/FilterCoordinator.swift b/TablePro/Core/Coordinators/FilterCoordinator.swift index e82c6c86a..7c66c5e1d 100644 --- a/TablePro/Core/Coordinators/FilterCoordinator.swift +++ b/TablePro/Core/Coordinators/FilterCoordinator.swift @@ -212,14 +212,33 @@ final class FilterCoordinator { } } + enum RemoveFilterOutcome: Equatable { + case noChange + case clear + case reapply([TableFilter]) + } + + static func removeFilterOutcome( + removing filter: TableFilter, + from appliedFilters: [TableFilter] + ) -> RemoveFilterOutcome { + guard appliedFilters.contains(where: { $0.id == filter.id }) else { return .noChange } + let remaining = appliedFilters.filter { $0.id != filter.id } + return remaining.isEmpty ? .clear : .reapply(remaining) + } + func removeFilterAndReload(_ filter: TableFilter) { - let wasApplied = selectedTabFilterState.appliedFilters.contains { $0.id == filter.id } + let outcome = Self.removeFilterOutcome( + removing: filter, + from: selectedTabFilterState.appliedFilters + ) removeFilter(filter) - guard wasApplied else { return } - let remaining = selectedTabFilterState.appliedFilters - if remaining.isEmpty { + switch outcome { + case .noChange: + break + case .clear: clearFiltersAndReload() - } else { + case .reapply(let remaining): applyFilters(remaining) } } diff --git a/TableProTests/Views/Main/FilterRestoreTests.swift b/TableProTests/Views/Main/FilterRestoreTests.swift index f2fc0efb3..30e237983 100644 --- a/TableProTests/Views/Main/FilterRestoreTests.swift +++ b/TableProTests/Views/Main/FilterRestoreTests.swift @@ -63,4 +63,26 @@ struct FilterRestoreTests { #expect(result.appliedFilters.isEmpty) #expect(!result.isVisible) } + + @Test("Removing a filter that isn't applied changes nothing") + func removeUnappliedFilterIsNoChange() { + let applied = TestFixtures.makeTableFilter(column: "email") + let other = TestFixtures.makeTableFilter(column: "name") + #expect(FilterCoordinator.removeFilterOutcome(removing: other, from: [applied]) == .noChange) + } + + @Test("Removing the only applied filter clears") + func removeOnlyAppliedFilterClears() { + let only = TestFixtures.makeTableFilter(column: "email") + #expect(FilterCoordinator.removeFilterOutcome(removing: only, from: [only]) == .clear) + } + + @Test("Removing one of several applied filters reapplies the rest") + func removeOneOfSeveralReappliesRemainder() { + let first = TestFixtures.makeTableFilter(column: "email") + let second = TestFixtures.makeTableFilter(column: "name") + #expect( + FilterCoordinator.removeFilterOutcome(removing: first, from: [first, second]) == .reapply([second]) + ) + } }