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 @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Reopening a table now restores the filter you had applied, instead of clearing it. Filters are remembered per connection. (#1347)
- 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.
- Importing from DBeaver no longer shows an unnecessary keychain permission warning. DBeaver stores passwords in its own file, so macOS never prompts.
Expand Down
47 changes: 35 additions & 12 deletions TablePro/Core/Coordinators/FilterCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -323,32 +323,55 @@ final class FilterCoordinator {
let tableName = tab.tableContext.tableName else { return }
FilterSettingsStorage.shared.saveLastFilters(
tab.filterState.appliedFilters,
for: tableName
for: tableName,
connectionId: parent.connectionId,
databaseName: tab.tableContext.databaseName,
schemaName: tab.tableContext.schemaName
)
}

func saveLastFilters(for tableName: String) {
guard let tab = parent.tabManager.selectedTab else { return }
FilterSettingsStorage.shared.saveLastFilters(
tab.filterState.appliedFilters,
for: tableName
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,
let tab = parent.tabManager.selectedTab else { return }

let restored = FilterSettingsStorage.shared.loadLastFilters(
for: tableName,
connectionId: parent.connectionId,
databaseName: tab.tableContext.databaseName,
schemaName: tab.tableContext.schemaName
)
mutateSelectedTabFilterState { state in
if settings.panelState == .restoreLast {
let restored = FilterSettingsStorage.shared.loadLastFilters(for: tableName)
if !restored.isEmpty {
state.filters = restored
state.appliedFilters = restored
}
}
if settings.panelState == .alwaysShow {
state.isVisible = true
}
state = Self.resolvedRestoredState(panelState: settings.panelState, saved: restored, current: state)
}
}

static func resolvedRestoredState(
panelState: FilterPanelDefaultState,
saved: [TableFilter],
current: TabFilterState
) -> TabFilterState {
guard panelState != .alwaysHide else { return current }
var state = current
if !saved.isEmpty {
state.filters = saved
state.appliedFilters = saved
state.isVisible = true
} else if panelState == .alwaysShow {
state.isVisible = true
}
return state
}

func clearFilterState() {
Expand Down
131 changes: 100 additions & 31 deletions TablePro/Core/Storage/FilterSettingsStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ struct FilterSettings: Codable, Equatable {
init(
defaultColumn: FilterDefaultColumn = .rawSQL,
defaultOperator: FilterDefaultOperator = .equal,
panelState: FilterPanelDefaultState = .alwaysHide
panelState: FilterPanelDefaultState = .restoreLast
) {
self.defaultColumn = defaultColumn
self.defaultOperator = defaultOperator
Expand All @@ -82,9 +82,10 @@ final class FilterSettingsStorage {
private static let legacyLastFiltersKeyPrefix = "com.TablePro.filter.lastFilters."
private static let legacyKnownFilterKeysKey = "com.TablePro.filter.knownFilterKeys"
private static let migrationCompleteKey = "com.TablePro.filterStateMigrationComplete"
private static let compositeKeyMigrationKey = "com.TablePro.filterStateCompositeKeyMigrationComplete"
private static let settingsKey = "com.TablePro.filter.settings"

private let settingsKey = "com.TablePro.filter.settings"
private let defaults = UserDefaults.standard
private let defaults: UserDefaults

private let filterStateDirectory: URL
private let encoder = JSONEncoder()
Expand All @@ -93,8 +94,13 @@ final class FilterSettingsStorage {
private var cachedSettings: FilterSettings?
private var lastFiltersCache: [String: [TableFilter]] = [:]

private init() {
filterStateDirectory = Self.resolvedFilterStateDirectory()
private convenience init() {
self.init(filterStateDirectory: Self.resolvedFilterStateDirectory(), defaults: .standard)
}

init(filterStateDirectory: URL, defaults: UserDefaults) {
self.filterStateDirectory = filterStateDirectory
self.defaults = defaults

do {
try FileManager.default.createDirectory(
Expand All @@ -105,13 +111,14 @@ final class FilterSettingsStorage {
Self.logger.error("Failed to create filter state directory: \(error.localizedDescription)")
}

Self.performMigrationIfNeeded(filterStateDirectory: filterStateDirectory)
Self.performMigrationIfNeeded(filterStateDirectory: filterStateDirectory, defaults: defaults)
Self.performCompositeKeyMigrationIfNeeded(filterStateDirectory: filterStateDirectory, defaults: defaults)
}

func loadSettings() -> FilterSettings {
if let cached = cachedSettings { return cached }

guard let data = defaults.data(forKey: settingsKey) else {
guard let data = defaults.data(forKey: Self.settingsKey) else {
let defaultSettings = FilterSettings()
cachedSettings = defaultSettings
return defaultSettings
Expand All @@ -133,58 +140,89 @@ final class FilterSettingsStorage {
cachedSettings = settings
do {
let data = try encoder.encode(settings)
defaults.set(data, forKey: settingsKey)
defaults.set(data, forKey: Self.settingsKey)
} catch {
Self.logger.error("Failed to encode filter settings: \(error)")
}
}

func loadLastFilters(for tableName: String) -> [TableFilter] {
let sanitized = sanitizeTableName(tableName)
if let cached = lastFiltersCache[sanitized] { return cached }

let fileURL = fileURL(forSanitizedName: sanitized)
func loadLastFilters(
for tableName: String,
connectionId: UUID,
databaseName: String,
schemaName: String?
) -> [TableFilter] {
let key = compositeKey(
tableName: tableName,
connectionId: connectionId,
databaseName: databaseName,
schemaName: schemaName
)
if let cached = lastFiltersCache[key] { return cached }

let fileURL = fileURL(forKey: key)
guard FileManager.default.fileExists(atPath: fileURL.path) else {
lastFiltersCache[sanitized] = []
lastFiltersCache[key] = []
return []
}

do {
let data = try Data(contentsOf: fileURL)
let filters = try decoder.decode([TableFilter].self, from: data)
lastFiltersCache[sanitized] = filters
lastFiltersCache[key] = filters
return filters
} catch {
Self.logger.error("Failed to load last filters for \(tableName): \(error)")
lastFiltersCache[sanitized] = []
lastFiltersCache[key] = []
return []
}
}

func saveLastFilters(_ filters: [TableFilter], for tableName: String) {
let sanitized = sanitizeTableName(tableName)
let fileURL = fileURL(forSanitizedName: sanitized)
func saveLastFilters(
_ filters: [TableFilter],
for tableName: String,
connectionId: UUID,
databaseName: String,
schemaName: String?
) {
let key = compositeKey(
tableName: tableName,
connectionId: connectionId,
databaseName: databaseName,
schemaName: schemaName
)
let fileURL = fileURL(forKey: key)

guard !filters.isEmpty else {
removeFile(at: fileURL, label: tableName)
lastFiltersCache.removeValue(forKey: sanitized)
lastFiltersCache.removeValue(forKey: key)
return
}

do {
let data = try encoder.encode(filters)
try data.write(to: fileURL, options: .atomic)
lastFiltersCache[sanitized] = filters
lastFiltersCache[key] = filters
} catch {
Self.logger.error("Failed to save last filters for \(tableName): \(error)")
}
}

func clearLastFilters(for tableName: String) {
let sanitized = sanitizeTableName(tableName)
let fileURL = fileURL(forSanitizedName: sanitized)
func clearLastFilters(
for tableName: String,
connectionId: UUID,
databaseName: String,
schemaName: String?
) {
let key = compositeKey(
tableName: tableName,
connectionId: connectionId,
databaseName: databaseName,
schemaName: schemaName
)
let fileURL = fileURL(forKey: key)
removeFile(at: fileURL, label: tableName)
lastFiltersCache.removeValue(forKey: sanitized)
lastFiltersCache.removeValue(forKey: key)
}

func clearAllLastFilters() {
Expand All @@ -200,8 +238,8 @@ final class FilterSettingsStorage {
lastFiltersCache.removeAll()
}

private func fileURL(forSanitizedName sanitized: String) -> URL {
filterStateDirectory.appendingPathComponent("\(sanitized).json")
private func fileURL(forKey key: String) -> URL {
filterStateDirectory.appendingPathComponent("\(key).json")
}

private func removeFile(at fileURL: URL, label: String) {
Expand All @@ -213,8 +251,15 @@ final class FilterSettingsStorage {
}
}

private func sanitizeTableName(_ tableName: String) -> String {
tableName.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? tableName
private func compositeKey(
tableName: String,
connectionId: UUID,
databaseName: String,
schemaName: String?
) -> String {
[connectionId.uuidString, databaseName, schemaName ?? "", tableName]
.map { $0.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? $0 }
.joined(separator: ".")
}

private static func resolvedFilterStateDirectory() -> URL {
Expand All @@ -227,8 +272,7 @@ final class FilterSettingsStorage {
.appendingPathComponent("FilterState", isDirectory: true)
}

private static func performMigrationIfNeeded(filterStateDirectory: URL) {
let defaults = UserDefaults.standard
private static func performMigrationIfNeeded(filterStateDirectory: URL, defaults: UserDefaults) {
guard !defaults.bool(forKey: migrationCompleteKey) else { return }

let allKeys = defaults.dictionaryRepresentation().keys
Expand Down Expand Up @@ -260,4 +304,29 @@ final class FilterSettingsStorage {
logger.trace("Migrated \(migrated) per-table filter entries to file storage")
}
}

private static func performCompositeKeyMigrationIfNeeded(filterStateDirectory: URL, defaults: UserDefaults) {
guard !defaults.bool(forKey: compositeKeyMigrationKey) else { return }

let fileManager = FileManager.default
if let files = try? fileManager.contentsOfDirectory(
at: filterStateDirectory,
includingPropertiesForKeys: nil
) {
for file in files where file.pathExtension == "json" {
try? fileManager.removeItem(at: file)
}
}

if let data = defaults.data(forKey: settingsKey),
var settings = try? JSONDecoder().decode(FilterSettings.self, from: data),
settings.panelState == .alwaysHide {
settings.panelState = .restoreLast
if let upgraded = try? JSONEncoder().encode(settings) {
defaults.set(upgraded, forKey: settingsKey)
}
}

defaults.set(true, forKey: compositeKeyMigrationKey)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ extension MainContentCoordinator {
if let tableName = tabManager.tabs[oldIndex].tableContext.tableName {
FilterSettingsStorage.shared.saveLastFilters(
tabManager.tabs[oldIndex].filterState.appliedFilters,
for: tableName
for: tableName,
connectionId: connectionId,
databaseName: tabManager.tabs[oldIndex].tableContext.databaseName,
schemaName: tabManager.tabs[oldIndex].tableContext.schemaName
)
}
persistOutgoingTabHiddenColumns(oldIndex: oldIndex)
Expand Down
34 changes: 15 additions & 19 deletions TablePro/Views/Main/Extensions/MainContentView+Setup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,17 @@ extension MainContentView {

switch payload.intent {
case .openContent:
if let selectedTab = tabManager.selectedTab,
selectedTab.tabType == .table,
let tableName = selectedTab.tableContext.tableName
{
coordinator.restoreLastHiddenColumnsForTable(tableName)
if selectedTab.filterState.appliedFilters.isEmpty {
coordinator.restoreFiltersForTable(tableName)
} else if let tabIndex = tabManager.selectedTabIndex {
coordinator.rebuildTableQuery(at: tabIndex)
}
}
if payload.skipAutoExecute {
_ = await schemaLoad
return
Expand All @@ -56,22 +67,6 @@ extension MainContentView {
{
await coordinator.switchDatabase(to: selectedTab.tableContext.databaseName)
} else {
if !selectedTab.filterState.appliedFilters.isEmpty,
let tableName = selectedTab.tableContext.tableName,
let tabIndex = tabManager.selectedTabIndex
{
let filteredQuery = coordinator.queryBuilder.buildFilteredQuery(
tableName: tableName,
filters: selectedTab.filterState.appliedFilters,
columns: [],
limit: selectedTab.pagination.pageSize,
offset: selectedTab.pagination.currentOffset
)
tabManager.mutate(at: tabIndex) { $0.content.query = filteredQuery }
}
if let tableName = selectedTab.tableContext.tableName {
coordinator.restoreLastHiddenColumnsForTable(tableName)
}
coordinator.executeTableTabQueryDirectly()
}
} else {
Expand Down Expand Up @@ -150,6 +145,10 @@ extension MainContentView {
if firstTab.tabType == .table,
!firstTab.content.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
if let tableName = firstTab.tableContext.tableName {
coordinator.restoreLastHiddenColumnsForTable(tableName)
coordinator.restoreFiltersForTable(tableName)
}
if let session = DatabaseManager.shared.activeSessions[connection.id],
session.isConnected
{
Expand All @@ -158,9 +157,6 @@ extension MainContentView {
{
Task { await coordinator.switchDatabase(to: firstTab.tableContext.databaseName) }
} else {
if let tableName = firstTab.tableContext.tableName {
coordinator.restoreLastHiddenColumnsForTable(tableName)
}
coordinator.executeTableTabQueryDirectly()
}
} else {
Expand Down
Loading
Loading