diff --git a/CHANGELOG.md b/CHANGELOG.md index 031c4128a..cf6a3a304 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,10 +12,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Connect through a SOCKS5 proxy. Set a host, port, and an optional username and password on the connection form's new SOCKS Proxy pane, alongside SSH Tunnel, Cloudflare Tunnel, and Cloud SQL Auth Proxy. The database hostname is resolved by the proxy, so names that only resolve behind it work. (#1882) - SQL Server connections can now use Windows Authentication (Kerberos) on macOS. Pick Windows Authentication in the connection form to sign in with the Kerberos ticket you already have from `kinit`, or enter a Kerberos principal and password to sign in with your domain credentials. Connect by hostname, not IP address. (#1879) - Teradata support through a downloadable driver written in native Swift. Connect over TD2 or TDNEGO logon, optionally with TLS, browse databases, tables, and columns, run SQL, edit rows, and create or alter tables. (#1867) +- The sidebar database tree now remembers which databases and schemas you had expanded, per connection, so reopening a window keeps them open. +- A Saved Customizations section in Settings lists the tables where you set column layouts or filters, and lets you reset any one of them, or all. +- Per-table column layouts (widths, order, and hidden columns) now sync across your Macs with iCloud when Settings sync is on. ### Changed - The query result row cap no longer adds a LIMIT to the SQL it sends. Your query now runs exactly as you wrote it and TablePro stops reading once it reaches the cap, which is how SQL Server and Oracle already worked. Adding a LIMIT could change how the server planned a query with an ORDER BY, and return different rows than the query you typed. (#1884) +- The Settings window groups the data grid, pagination, result formatting, JSON viewer, query history, and saved per-table customizations under a new Data tab, and moves recent tables, object comments, and default sidebar layout under the General tab. ### Fixed @@ -36,6 +40,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed the query result row cap returning a single row when it was set to unlimited. (#1884) - Fixed SSH tunnels that could accept a database connection and then go quiet instead of forwarding it or failing, which showed up as MySQL and MariaDB connections timing out while reading the server greeting. A tunnel that cannot open its forwarding channel now gives up after 10 seconds, closes the connection, and logs the reason, instead of leaving the driver to wait out its own timeout with no explanation. (#1883) - Fixed connections being reset when a single database session opened several at once through one SSH tunnel, which could happen while browsing schemas. (#1883) +- Per-column display formats (Display As) are now kept per table, so two tables with the same name in different databases or schemas no longer share formatting. +- Reopening a table now restores a saved filter's AND/OR match mode, instead of always resetting it to AND. ## [0.57.1] - 2026-07-15 diff --git a/TablePro/Core/Coordinators/FilterCoordinator.swift b/TablePro/Core/Coordinators/FilterCoordinator.swift index 9e192b7ce..b3841fb53 100644 --- a/TablePro/Core/Coordinators/FilterCoordinator.swift +++ b/TablePro/Core/Coordinators/FilterCoordinator.swift @@ -427,6 +427,7 @@ final class FilterCoordinator { let tableName = tab.tableContext.tableName else { return } FilterSettingsStorage.shared.saveLastFilters( tab.filterState.filters.filter(\.isValid), + logicMode: tab.filterState.filterLogicMode, for: tableName, connectionId: parent.connectionId, databaseName: tab.tableContext.databaseName, @@ -438,6 +439,7 @@ final class FilterCoordinator { guard let tab = parent.tabManager.selectedTab else { return } FilterSettingsStorage.shared.saveLastFilters( tab.filterState.filters.filter(\.isValid), + logicMode: tab.filterState.filterLogicMode, for: tableName, connectionId: parent.connectionId, databaseName: tab.tableContext.databaseName, @@ -459,11 +461,11 @@ final class FilterCoordinator { let settings = FilterSettingsStorage.shared.loadSettings() guard let tab = parent.tabManager.selectedTab else { return } - let restored: [TableFilter] + let saved: PersistedFilterState if settings.panelState == .alwaysHide { - restored = [] + saved = PersistedFilterState(filters: []) } else { - restored = FilterSettingsStorage.shared.loadLastFilters( + saved = FilterSettingsStorage.shared.loadLastFilterState( for: tableName, connectionId: parent.connectionId, databaseName: tab.tableContext.databaseName, @@ -471,13 +473,19 @@ final class FilterCoordinator { ) } mutateSelectedTabFilterState { state in - state = Self.resolvedRestoredState(panelState: settings.panelState, saved: restored, current: state) + state = Self.resolvedRestoredState( + panelState: settings.panelState, + saved: saved.filters, + savedLogicMode: saved.logicMode, + current: state + ) } } static func resolvedRestoredState( panelState: FilterPanelDefaultState, saved: [TableFilter], + savedLogicMode: FilterLogicMode = .and, current: TabFilterState ) -> TabFilterState { var state = current @@ -490,10 +498,12 @@ final class FilterCoordinator { state.filters = saved state.commit = .all state.isVisible = true + state.filterLogicMode = savedLogicMode case .restoreLast: state.filters = saved state.commit = .all state.isVisible = !saved.isEmpty + state.filterLogicMode = savedLogicMode } return state } diff --git a/TablePro/Core/Database/FilterSQLGenerator.swift b/TablePro/Core/Database/FilterSQLGenerator.swift index ffdba57ca..ff252b263 100644 --- a/TablePro/Core/Database/FilterSQLGenerator.swift +++ b/TablePro/Core/Database/FilterSQLGenerator.swift @@ -42,7 +42,7 @@ struct FilterSQLGenerator { guard filter.isValid else { return nil } if filter.isRawSQL, let rawSQL = filter.rawSQL { - guard isRawSQLSafe(rawSQL) else { return nil } + guard SQLBoundaryValidator.isRawFilterConditionSafe(rawSQL) else { return nil } return "(\(rawSQL))" } @@ -269,34 +269,6 @@ struct FilterSQLGenerator { .replacingOccurrences(of: "_", with: "!_") } - // MARK: - Raw SQL Validation - - private static let destructiveStatementPattern: NSRegularExpression? = { - let keywords = "DROP|DELETE|INSERT|UPDATE|ALTER|CREATE|TRUNCATE|GRANT|REVOKE|EXEC|EXECUTE" - let pattern = ";\\s*(\(keywords))\\b" - return try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) - }() - - private static let commentInjectionPattern: NSRegularExpression? = { - try? NSRegularExpression(pattern: "(?:^|\\s)--|\\/\\*", options: []) - }() - - private func isRawSQLSafe(_ sql: String) -> Bool { - let range = NSRange(sql.startIndex..., in: sql) - - if let pattern = Self.destructiveStatementPattern, - pattern.firstMatch(in: sql, range: range) != nil { - return false - } - - if let pattern = Self.commentInjectionPattern, - pattern.firstMatch(in: sql, range: range) != nil { - return false - } - - return true - } - // MARK: - List Parsing private func parseListValues(_ input: String) -> [String] { diff --git a/TablePro/Core/Database/SQLBoundaryValidator.swift b/TablePro/Core/Database/SQLBoundaryValidator.swift new file mode 100644 index 000000000..a8f276165 --- /dev/null +++ b/TablePro/Core/Database/SQLBoundaryValidator.swift @@ -0,0 +1,34 @@ +// +// SQLBoundaryValidator.swift +// TablePro +// + +import Foundation + +enum SQLBoundaryValidator { + private static let destructiveStatementPattern: NSRegularExpression? = { + let keywords = "DROP|DELETE|INSERT|UPDATE|ALTER|CREATE|TRUNCATE|GRANT|REVOKE|EXEC|EXECUTE" + let pattern = ";\\s*(\(keywords))\\b" + return try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) + }() + + private static let commentInjectionPattern: NSRegularExpression? = { + try? NSRegularExpression(pattern: "(?:^|\\s)--|\\/\\*", options: []) + }() + + static func isRawFilterConditionSafe(_ sql: String) -> Bool { + let range = NSRange(sql.startIndex..., in: sql) + + if let pattern = destructiveStatementPattern, + pattern.firstMatch(in: sql, range: range) != nil { + return false + } + + if let pattern = commentInjectionPattern, + pattern.firstMatch(in: sql, range: range) != nil { + return false + } + + return true + } +} diff --git a/TablePro/Core/Services/Formatting/ValueDisplayFormatService.swift b/TablePro/Core/Services/Formatting/ValueDisplayFormatService.swift index 82fef04d9..d1bbd752a 100644 --- a/TablePro/Core/Services/Formatting/ValueDisplayFormatService.swift +++ b/TablePro/Core/Services/Formatting/ValueDisplayFormatService.swift @@ -15,7 +15,6 @@ final class ValueDisplayFormatService { private static let logger = Logger(subsystem: "com.TablePro", category: "ValueDisplayFormat") - /// Auto-detected formats keyed by "connectionId.tableName.columnName" for per-connection isolation. private var autoDetectedFormats: [String: ValueDisplayFormat] = [:] private(set) var overridesVersion: Int = 0 @@ -41,48 +40,42 @@ final class ValueDisplayFormatService { // MARK: - Effective Format Resolution - func effectiveFormat(columnName: String, connectionId: UUID?, tableName: String?) -> ValueDisplayFormat { - // Stored overrides take priority - if let connId = connectionId, let table = tableName { - if let overrides = ValueDisplayFormatStorage.shared.load(for: table, connectionId: connId), - let format = overrides[columnName] { - return format - } + func effectiveFormat(columnName: String, scope: TableScope?) -> ValueDisplayFormat { + if let scope, + let overrides = ValueDisplayFormatStorage.shared.load(for: scope), + let format = overrides[columnName] { + return format } - // Then auto-detected (scoped by connection + table) - let key = scopedKey(columnName: columnName, connectionId: connectionId, tableName: tableName) - if let format = autoDetectedFormats[key] { + if let format = autoDetectedFormats[scopedKey(columnName: columnName, scope: scope)] { return format } return .raw } - func setAutoDetectedFormats(_ formats: [String: ValueDisplayFormat], connectionId: UUID?, tableName: String?) { - // Clear previous entries for this scope - let prefix = scopePrefix(connectionId: connectionId, tableName: tableName) + func setAutoDetectedFormats(_ formats: [String: ValueDisplayFormat], scope: TableScope?) { + let prefix = scopePrefix(scope: scope) autoDetectedFormats = autoDetectedFormats.filter { !$0.key.hasPrefix(prefix) } for (columnName, format) in formats { - let key = scopedKey(columnName: columnName, connectionId: connectionId, tableName: tableName) - autoDetectedFormats[key] = format + autoDetectedFormats[scopedKey(columnName: columnName, scope: scope)] = format } } - func clearAutoDetectedFormats(connectionId: UUID?, tableName: String?) { - let prefix = scopePrefix(connectionId: connectionId, tableName: tableName) + func clearAutoDetectedFormats(scope: TableScope?) { + let prefix = scopePrefix(scope: scope) autoDetectedFormats = autoDetectedFormats.filter { !$0.key.hasPrefix(prefix) } } // MARK: - Scoping - private func scopePrefix(connectionId: UUID?, tableName: String?) -> String { - "\(connectionId?.uuidString ?? "_").\(tableName ?? "_")." + private func scopePrefix(scope: TableScope?) -> String { + "\(scope?.storageComponent ?? "_")." } - private func scopedKey(columnName: String, connectionId: UUID?, tableName: String?) -> String { - "\(connectionId?.uuidString ?? "_").\(tableName ?? "_").\(columnName)" + private func scopedKey(columnName: String, scope: TableScope?) -> String { + "\(scope?.storageComponent ?? "_").\(columnName)" } // MARK: - Override Management @@ -90,10 +83,9 @@ final class ValueDisplayFormatService { func setOverride( _ format: ValueDisplayFormat?, columnName: String, - connectionId: UUID, - tableName: String + scope: TableScope ) { - var overrides = ValueDisplayFormatStorage.shared.load(for: tableName, connectionId: connectionId) ?? [:] + var overrides = ValueDisplayFormatStorage.shared.load(for: scope) ?? [:] if let format, format != .raw { overrides[columnName] = format @@ -102,9 +94,9 @@ final class ValueDisplayFormatService { } if overrides.isEmpty { - ValueDisplayFormatStorage.shared.clear(for: tableName, connectionId: connectionId) + ValueDisplayFormatStorage.shared.clear(for: scope) } else { - ValueDisplayFormatStorage.shared.save(overrides, for: tableName, connectionId: connectionId) + ValueDisplayFormatStorage.shared.save(overrides, for: scope) } overridesVersion &+= 1 diff --git a/TablePro/Core/Services/Infrastructure/WindowOpener.swift b/TablePro/Core/Services/Infrastructure/WindowOpener.swift index 6e047abfa..3c0e87045 100644 --- a/TablePro/Core/Services/Infrastructure/WindowOpener.swift +++ b/TablePro/Core/Services/Infrastructure/WindowOpener.swift @@ -29,9 +29,9 @@ internal final class WindowOpener { run { $0.openWelcomeAction?() } } - internal func openSettings(tab: SettingsTab? = nil) { + internal func openSettings(tab: SettingsPane? = nil) { if let tab { - UserDefaults.standard.set(tab.rawValue, forKey: "selectedSettingsTab") + UserDefaults.standard.set(tab.rawValue, forKey: PreferenceKeys.selectedSettingsPane.name) } run { $0.openSettingsAction?() } } diff --git a/TablePro/Core/Storage/AIKeyStorage.swift b/TablePro/Core/Storage/AIKeyStorage.swift index 6d5d4a514..a6487171c 100644 --- a/TablePro/Core/Storage/AIKeyStorage.swift +++ b/TablePro/Core/Storage/AIKeyStorage.swift @@ -27,25 +27,8 @@ final class AIKeyStorage { func loadAPIKey(for providerID: UUID) -> String? { let key = "com.TablePro.aikey.\(providerID.uuidString)" - let pid = providerID.uuidString - switch keychain.readStringResult(forKey: key) { - case .found(let value): - return value - case .notFound: - return nil - case .locked: - Self.logger.warning("AI API key unavailable: Keychain locked (providerID=\(pid, privacy: .public))") - return nil - case .userCancelled: - Self.logger.notice("AI API key prompt cancelled (providerID=\(pid, privacy: .public))") - return nil - case .authFailed: - Self.logger.warning("AI API key auth failed (providerID=\(pid, privacy: .public))") - return nil - case .error(let status): - Self.logger.error("AI API key read error \(status) (providerID=\(pid, privacy: .public))") - return nil - } + return keychain.readStringResult(forKey: key) + .value(label: "AI API key (providerID=\(providerID.uuidString))", logger: Self.logger) } func deleteAPIKey(for providerID: UUID) { diff --git a/TablePro/Core/Storage/AppSettingsManager.swift b/TablePro/Core/Storage/AppSettingsManager.swift index 582710c1a..ec39c8d84 100644 --- a/TablePro/Core/Storage/AppSettingsManager.swift +++ b/TablePro/Core/Storage/AppSettingsManager.swift @@ -9,12 +9,6 @@ import os final class AppSettingsManager { static let shared = AppSettingsManager() - deinit { - if let observer = accessibilityTextSizeObserver { - NSWorkspace.shared.notificationCenter.removeObserver(observer) - } - } - var general: GeneralSettings { didSet { general.language.apply() @@ -187,8 +181,6 @@ final class AppSettingsManager { @ObservationIgnored private let mcpServerManager: MCPServerManager @ObservationIgnored private let copilotService: CopilotService @ObservationIgnored private var isValidating = false - @ObservationIgnored private var accessibilityTextSizeObserver: NSObjectProtocol? - @ObservationIgnored private var lastAccessibilityScale: CGFloat = 1.0 init( storage: AppSettingsStorage = .shared, @@ -237,8 +229,6 @@ final class AppSettingsManager { dateFormattingService.updateFormat(dataGrid.dateFormat) - observeAccessibilityTextSizeChanges() - if ai.enabled, ai.providers.contains(where: { $0.type == .copilot }) { Task { [copilotService] in await copilotService.start() } } @@ -259,25 +249,6 @@ final class AppSettingsManager { private static let logger = Logger(subsystem: "com.TablePro", category: "AppSettingsManager") - private func observeAccessibilityTextSizeChanges() { - lastAccessibilityScale = EditorFontCache.computeAccessibilityScale() - accessibilityTextSizeObserver = NSWorkspace.shared.notificationCenter.addObserver( - forName: NSWorkspace.accessibilityDisplayOptionsDidChangeNotification, - object: nil, - queue: .main - ) { [weak self] _ in - Task { @MainActor [weak self] in - guard let self else { return } - let newScale = EditorFontCache.computeAccessibilityScale() - guard abs(newScale - lastAccessibilityScale) > 0.01 else { return } - lastAccessibilityScale = newScale - Self.logger.debug("Accessibility text size changed, scale: \(newScale, format: .fixed(precision: 2))") - themeEngine.reloadFontCaches() - appEvents.accessibilityTextSizeChanged.send(()) - } - } - } - private func applyHistorySettingsImmediately() async { await queryHistoryManager.applySettingsChange() } diff --git a/TablePro/Core/Storage/AppSettingsStorage.swift b/TablePro/Core/Storage/AppSettingsStorage.swift index ae57d1b48..d37db5f48 100644 --- a/TablePro/Core/Storage/AppSettingsStorage.swift +++ b/TablePro/Core/Storage/AppSettingsStorage.swift @@ -33,6 +33,8 @@ final class AppSettingsStorage { static let mcp = "com.TablePro.settings.mcp" static let hasCompletedOnboarding = "com.TablePro.settings.hasCompletedOnboarding" static let startupReopenMigration = "com.TablePro.settings.didMigrateStartupToReopenLast" + static let jsonFieldHeightMigration = "com.TablePro.settings.didMigrateJsonFieldHeightKey" + static let legacyJsonFieldHeight = "rightSidebar.jsonFieldHeight" } init(userDefaults: UserDefaults = .standard) { @@ -60,6 +62,17 @@ final class AppSettingsStorage { saveGeneral(general) } + func migrateJsonFieldHeightKeyIfNeeded() { + guard !defaults.bool(forKey: Keys.jsonFieldHeightMigration) else { return } + defaults.set(true, forKey: Keys.jsonFieldHeightMigration) + + let newKey = PreferenceKeys.rowInspectorJsonFieldHeight.name + guard defaults.object(forKey: Keys.legacyJsonFieldHeight) != nil, + defaults.object(forKey: newKey) == nil else { return } + defaults.set(defaults.double(forKey: Keys.legacyJsonFieldHeight), forKey: newKey) + defaults.removeObject(forKey: Keys.legacyJsonFieldHeight) + } + // MARK: - Appearance Settings func loadAppearance() -> AppearanceSettings { @@ -205,6 +218,9 @@ final class AppSettingsStorage { saveAI(.default) saveSync(.default) saveMCP(.default) + defaults.removeObject(forKey: PreferenceKeys.selectedSettingsPane.name) + defaults.removeObject(forKey: PreferenceKeys.rowInspectorJsonFieldHeight.name) + defaults.removeObject(forKey: SidebarPersistenceKey.defaultLayout) } // MARK: - Helpers diff --git a/TablePro/Core/Storage/ColumnLayoutPersister.swift b/TablePro/Core/Storage/ColumnLayoutPersister.swift index 773af50ef..6e435e8fd 100644 --- a/TablePro/Core/Storage/ColumnLayoutPersister.swift +++ b/TablePro/Core/Storage/ColumnLayoutPersister.swift @@ -22,16 +22,23 @@ final class FileColumnLayoutPersister: ColumnLayoutPersisting { private struct PersistedColumnLayout: Codable { var columnWidths: [String: CGFloat] var columnOrder: [String]? + var hiddenColumns: [String]? } + static let syncCategoryPrefix = "columnLayout." + private let storageDirectory: URL + private let defaults: UserDefaults + private let syncTracker: SyncChangeTracker private let encoder = JSONEncoder() private let decoder = JSONDecoder() private var cache: [UUID: [String: PersistedColumnLayout]] = [:] - init(storageDirectory: URL? = nil) { + init(storageDirectory: URL? = nil, defaults: UserDefaults = .standard, syncTracker: SyncChangeTracker = .shared) { self.storageDirectory = storageDirectory ?? Self.resolvedStorageDirectory() + self.defaults = defaults + self.syncTracker = syncTracker do { try FileManager.default.createDirectory( @@ -46,20 +53,20 @@ final class FileColumnLayoutPersister: ColumnLayoutPersisting { func save(_ layout: ColumnLayoutState, for key: ColumnLayoutTableKey) { guard !layout.columnWidths.isEmpty else { return } - let persisted = PersistedColumnLayout( - columnWidths: layout.columnWidths, - columnOrder: layout.columnOrder - ) - var entries = loadEntries(for: key.connectionId) - entries[key.storageKey] = persisted + var entry = entries[key.storageKey] ?? PersistedColumnLayout(columnWidths: [:], columnOrder: nil, hiddenColumns: nil) + entry.columnWidths = layout.columnWidths + entry.columnOrder = layout.columnOrder + entries[key.storageKey] = entry cache[key.connectionId] = entries writeEntries(entries, for: key.connectionId) + syncTracker.markDirty(.settings, id: Self.syncCategory(for: key.storageKey)) } func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? { let entries = loadEntries(for: key.connectionId) - guard let persisted = entries[key.storageKey] else { return nil } + guard let persisted = entries[key.storageKey], + !persisted.columnWidths.isEmpty || persisted.columnOrder != nil else { return nil } var state = ColumnLayoutState() state.columnWidths = persisted.columnWidths @@ -67,7 +74,35 @@ final class FileColumnLayoutPersister: ColumnLayoutPersisting { return state } + func loadHiddenColumns(for key: ColumnLayoutTableKey) -> Set { + let entries = loadEntries(for: key.connectionId) + if let hidden = entries[key.storageKey]?.hiddenColumns { + return Set(hidden) + } + return migrateLegacyHidden(for: key) + } + + func saveHiddenColumns(_ hidden: Set, for key: ColumnLayoutTableKey) { + removeLegacyHidden(for: key) + + var entries = loadEntries(for: key.connectionId) + var entry = entries[key.storageKey] ?? PersistedColumnLayout(columnWidths: [:], columnOrder: nil, hiddenColumns: nil) + entry.hiddenColumns = hidden.isEmpty ? nil : Array(hidden) + + if entry.columnWidths.isEmpty, entry.columnOrder == nil, entry.hiddenColumns == nil { + clear(for: key) + return + } + + entries[key.storageKey] = entry + cache[key.connectionId] = entries + writeEntries(entries, for: key.connectionId) + syncTracker.markDirty(.settings, id: Self.syncCategory(for: key.storageKey)) + } + func clear(for key: ColumnLayoutTableKey) { + removeLegacyHidden(for: key) + var entries = loadEntries(for: key.connectionId) guard entries.removeValue(forKey: key.storageKey) != nil else { return } @@ -78,6 +113,52 @@ final class FileColumnLayoutPersister: ColumnLayoutPersisting { cache[key.connectionId] = entries writeEntries(entries, for: key.connectionId) } + syncTracker.markDeleted(.settings, id: Self.syncCategory(for: key.storageKey)) + } + + static func syncCategory(for storageKey: String) -> String { + syncCategoryPrefix + storageKey + } + + func rawData(forStorageKey storageKey: String) -> Data? { + guard let scope = TableScope(storageComponent: storageKey), + let entry = loadEntries(for: scope.connectionId)[storageKey] else { return nil } + return try? encoder.encode(entry) + } + + func applyRemote(storageKey: String, data: Data) { + guard let scope = TableScope(storageComponent: storageKey), + let entry = try? decoder.decode(PersistedColumnLayout.self, from: data) else { return } + var entries = loadEntries(for: scope.connectionId) + entries[storageKey] = entry + cache[scope.connectionId] = entries + writeEntries(entries, for: scope.connectionId) + } + + func customizedStorageKeys() -> [String] { + guard let files = try? FileManager.default.contentsOfDirectory( + at: storageDirectory, + includingPropertiesForKeys: nil + ) else { return [] } + + var keys: [String] = [] + for file in files where file.pathExtension == "json" { + guard let connectionId = UUID(uuidString: file.deletingPathExtension().lastPathComponent) else { continue } + keys.append(contentsOf: loadEntries(for: connectionId).keys) + } + return keys + } + + private func migrateLegacyHidden(for key: ColumnLayoutTableKey) -> Set { + guard let array = defaults.stringArray(forKey: Self.legacyVisibilityPrefix + key.storageKey), + !array.isEmpty else { return [] } + let hidden = Set(array) + saveHiddenColumns(hidden, for: key) + return hidden + } + + private func removeLegacyHidden(for key: ColumnLayoutTableKey) { + defaults.removeObject(forKey: Self.legacyVisibilityPrefix + key.storageKey) } private func loadEntries(for connectionId: UUID) -> [String: PersistedColumnLayout] { @@ -142,7 +223,6 @@ final class FileColumnLayoutPersister: ColumnLayoutPersisting { } private func performScopeMigration() { - let defaults = UserDefaults.standard guard !defaults.bool(forKey: Self.scopeMigrationKey) else { return } if let files = try? FileManager.default.contentsOfDirectory( diff --git a/TablePro/Core/Storage/ColumnLayoutPersisting.swift b/TablePro/Core/Storage/ColumnLayoutPersisting.swift index 00fa1ac39..b669ccaff 100644 --- a/TablePro/Core/Storage/ColumnLayoutPersisting.swift +++ b/TablePro/Core/Storage/ColumnLayoutPersisting.swift @@ -26,4 +26,11 @@ protocol ColumnLayoutPersisting: AnyObject { func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? func save(_ layout: ColumnLayoutState, for key: ColumnLayoutTableKey) func clear(for key: ColumnLayoutTableKey) + func loadHiddenColumns(for key: ColumnLayoutTableKey) -> Set + func saveHiddenColumns(_ hidden: Set, for key: ColumnLayoutTableKey) +} + +extension ColumnLayoutPersisting { + func loadHiddenColumns(for key: ColumnLayoutTableKey) -> Set { [] } + func saveHiddenColumns(_ hidden: Set, for key: ColumnLayoutTableKey) {} } diff --git a/TablePro/Core/Storage/ColumnVisibilityPersistence.swift b/TablePro/Core/Storage/ColumnVisibilityPersistence.swift deleted file mode 100644 index 03a33c4d4..000000000 --- a/TablePro/Core/Storage/ColumnVisibilityPersistence.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// ColumnVisibilityPersistence.swift -// TablePro -// - -import Foundation - -enum ColumnVisibilityPersistence { - private static let keyPrefix = "com.TablePro.columns.hiddenColumns." - - static func key(for tableKey: ColumnLayoutTableKey) -> String { - keyPrefix + tableKey.storageKey - } - - static func loadHiddenColumns( - for tableKey: ColumnLayoutTableKey, - defaults: UserDefaults = .standard - ) -> Set { - guard let array = defaults.stringArray(forKey: key(for: tableKey)) else { return [] } - return Set(array) - } - - static func saveHiddenColumns( - _ hiddenColumns: Set, - for tableKey: ColumnLayoutTableKey, - defaults: UserDefaults = .standard - ) { - let storageKey = key(for: tableKey) - if hiddenColumns.isEmpty { - defaults.removeObject(forKey: storageKey) - } else { - defaults.set(Array(hiddenColumns), forKey: storageKey) - } - } -} diff --git a/TablePro/Core/Storage/CompositeStorageKey.swift b/TablePro/Core/Storage/CompositeStorageKey.swift index 6c85be160..1f1d4e795 100644 --- a/TablePro/Core/Storage/CompositeStorageKey.swift +++ b/TablePro/Core/Storage/CompositeStorageKey.swift @@ -12,8 +12,11 @@ enum CompositeStorageKey { schemaName: String?, tableName: String ) -> String { - [connectionId.uuidString, databaseName, schemaName ?? "", tableName] - .map { $0.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? $0 } - .joined(separator: ".") + TableScope( + connectionId: connectionId, + database: databaseName, + schema: schemaName, + table: tableName + ).storageComponent } } diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index bf5e947b8..b83b42458 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -580,26 +580,8 @@ final class ConnectionStorage { } private func resolveString(_ context: SecretContext, forKey key: String) -> String? { - let label = context.label - let connId = context.connectionId.uuidString - switch keychain.readStringResult(forKey: key) { - case .found(let value): - return value - case .notFound: - return nil - case .locked: - Self.logger.warning("\(label, privacy: .public) unavailable: Keychain locked (connId=\(connId, privacy: .public))") - return nil - case .userCancelled: - Self.logger.notice("\(label, privacy: .public) prompt cancelled (connId=\(connId, privacy: .public))") - return nil - case .authFailed: - Self.logger.warning("\(label, privacy: .public) auth failed (connId=\(connId, privacy: .public))") - return nil - case .error(let status): - Self.logger.error("\(label, privacy: .public) read error \(status) (connId=\(connId, privacy: .public))") - return nil - } + keychain.readStringResult(forKey: key) + .value(label: "\(context.label) (connId=\(context.connectionId.uuidString))", logger: Self.logger) } // MARK: - Plugin Secure Field Migration diff --git a/TablePro/Core/Storage/FilterSettingsStorage.swift b/TablePro/Core/Storage/FilterSettingsStorage.swift index f6d85321d..19b90feea 100644 --- a/TablePro/Core/Storage/FilterSettingsStorage.swift +++ b/TablePro/Core/Storage/FilterSettingsStorage.swift @@ -93,7 +93,7 @@ final class FilterSettingsStorage { private let ioQueue = DispatchQueue(label: "com.TablePro.FilterSettingsStorage.io", qos: .utility) private var cachedSettings: FilterSettings? - private var lastFiltersCache: [String: [TableFilter]] = [:] + private var lastFiltersCache: [String: PersistedFilterState] = [:] private var browseSearchCache: [String: BrowseSearchState] = [:] private convenience init() { @@ -154,6 +154,20 @@ final class FilterSettingsStorage { databaseName: String, schemaName: String? ) -> [TableFilter] { + loadLastFilterState( + for: tableName, + connectionId: connectionId, + databaseName: databaseName, + schemaName: schemaName + ).filters + } + + func loadLastFilterState( + for tableName: String, + connectionId: UUID, + databaseName: String, + schemaName: String? + ) -> PersistedFilterState { let key = compositeKey( tableName: tableName, connectionId: connectionId, @@ -164,24 +178,27 @@ final class FilterSettingsStorage { let fileURL = fileURL(forKey: key) guard FileManager.default.fileExists(atPath: fileURL.path) else { - lastFiltersCache[key] = [] - return [] + let empty = PersistedFilterState(filters: []) + lastFiltersCache[key] = empty + return empty } do { let data = try Data(contentsOf: fileURL) - let filters = try decoder.decode([TableFilter].self, from: data) - lastFiltersCache[key] = filters - return filters + let state = try decoder.decode(PersistedFilterState.self, from: data) + lastFiltersCache[key] = state + return state } catch { Self.logger.error("Failed to load last filters for \(tableName): \(error)") - lastFiltersCache[key] = [] - return [] + let empty = PersistedFilterState(filters: []) + lastFiltersCache[key] = empty + return empty } } func saveLastFilters( _ filters: [TableFilter], + logicMode: FilterLogicMode = .and, for tableName: String, connectionId: UUID, databaseName: String, @@ -203,9 +220,10 @@ final class FilterSettingsStorage { return } - lastFiltersCache[key] = filters + let state = PersistedFilterState(filters: filters, logicMode: logicMode) + lastFiltersCache[key] = state do { - let data = try encoder.encode(filters) + let data = try encoder.encode(state) ioQueue.async { do { try data.write(to: fileURL, options: .atomic) @@ -377,6 +395,18 @@ final class FilterSettingsStorage { } } + func customizedStorageKeys() -> [String] { + guard let files = try? FileManager.default.contentsOfDirectory( + at: filterStateDirectory, + includingPropertiesForKeys: nil + ) else { return [] } + + return files + .filter { $0.pathExtension == "json" } + .map { $0.deletingPathExtension().lastPathComponent } + .filter { !$0.hasSuffix(".browse") } + } + private func fileURL(forKey key: String) -> URL { filterStateDirectory.appendingPathComponent("\(key).json") } diff --git a/TablePro/Core/Storage/KeychainStringResult+Value.swift b/TablePro/Core/Storage/KeychainStringResult+Value.swift new file mode 100644 index 000000000..a77e806ff --- /dev/null +++ b/TablePro/Core/Storage/KeychainStringResult+Value.swift @@ -0,0 +1,32 @@ +// +// KeychainStringResult+Value.swift +// TablePro +// + +import Foundation +import os + +extension KeychainStringResult { + /// Maps a Keychain read to its string value, returning nil for every + /// non-fatal outcome and logging the recoverable failures under `label`. + func value(label: String, logger: Logger) -> String? { + switch self { + case .found(let value): + return value + case .notFound: + return nil + case .locked: + logger.warning("\(label, privacy: .public) unavailable: Keychain locked") + return nil + case .userCancelled: + logger.notice("\(label, privacy: .public) prompt cancelled") + return nil + case .authFailed: + logger.warning("\(label, privacy: .public) auth failed") + return nil + case .error(let status): + logger.error("\(label, privacy: .public) read error \(status)") + return nil + } + } +} diff --git a/TablePro/Core/Storage/LicenseStorage.swift b/TablePro/Core/Storage/LicenseStorage.swift index 4266d5584..190d9491f 100644 --- a/TablePro/Core/Storage/LicenseStorage.swift +++ b/TablePro/Core/Storage/LicenseStorage.swift @@ -34,24 +34,8 @@ final class LicenseStorage { } func loadLicenseKey() -> String? { - switch keychain.readStringResult(forKey: Keys.keychainLicenseKey) { - case .found(let value): - return value - case .notFound: - return nil - case .locked: - Self.logger.warning("License key unavailable: Keychain locked") - return nil - case .userCancelled: - Self.logger.notice("License key prompt cancelled") - return nil - case .authFailed: - Self.logger.warning("License key auth failed") - return nil - case .error(let status): - Self.logger.error("License key read error \(status)") - return nil - } + keychain.readStringResult(forKey: Keys.keychainLicenseKey) + .value(label: "License key", logger: Self.logger) } func deleteLicenseKey() { diff --git a/TablePro/Core/Storage/LinkedFolderStorage.swift b/TablePro/Core/Storage/LinkedFolderStorage.swift index 49dc1851f..8a46d50ee 100644 --- a/TablePro/Core/Storage/LinkedFolderStorage.swift +++ b/TablePro/Core/Storage/LinkedFolderStorage.swift @@ -2,11 +2,8 @@ // LinkedFolderStorage.swift // TablePro // -// UserDefaults persistence for linked folder paths. -// import Foundation -import os import TableProImport struct LinkedFolder: Codable, Identifiable, Hashable { @@ -26,39 +23,26 @@ struct LinkedFolder: Codable, Identifiable, Hashable { final class LinkedFolderStorage { static let shared = LinkedFolderStorage() - private static let logger = Logger(subsystem: "com.TablePro", category: "LinkedFolderStorage") - private let key = "com.TablePro.linkedFolders" - private init() {} + private let store: CodableListPreferenceStore + + init(defaults: KeyValueStore = UserDefaults.standard) { + store = CodableListPreferenceStore(key: PreferenceKeys.linkedFolders, store: defaults) + } func loadFolders() -> [LinkedFolder] { - guard let data = UserDefaults.standard.data(forKey: key) else { return [] } - do { - return try JSONDecoder().decode([LinkedFolder].self, from: data) - } catch { - Self.logger.error("Failed to decode linked folders: \(error.localizedDescription, privacy: .public)") - return [] - } + store.load() } func saveFolders(_ folders: [LinkedFolder]) { - do { - let data = try JSONEncoder().encode(folders) - UserDefaults.standard.set(data, forKey: key) - } catch { - Self.logger.error("Failed to encode linked folders: \(error.localizedDescription, privacy: .public)") - } + store.save(folders) } func addFolder(_ folder: LinkedFolder) { - var folders = loadFolders() - folders.append(folder) - saveFolders(folders) + store.add(folder) } func removeFolder(_ folder: LinkedFolder) { - var folders = loadFolders() - folders.removeAll { $0.id == folder.id } - saveFolders(folders) + store.remove(id: folder.id) } } diff --git a/TablePro/Core/Storage/LinkedSQLFolderStorage.swift b/TablePro/Core/Storage/LinkedSQLFolderStorage.swift index b0c86137d..54f1d6f7a 100644 --- a/TablePro/Core/Storage/LinkedSQLFolderStorage.swift +++ b/TablePro/Core/Storage/LinkedSQLFolderStorage.swift @@ -4,50 +4,33 @@ // import Foundation -import os internal final class LinkedSQLFolderStorage: @unchecked Sendable { static let shared = LinkedSQLFolderStorage() - private static let logger = Logger(subsystem: "com.TablePro", category: "LinkedSQLFolderStorage") - private let key = "com.TablePro.linkedSQLFolders" - private init() {} + private let store: CodableListPreferenceStore + + init(defaults: KeyValueStore = UserDefaults.standard) { + store = CodableListPreferenceStore(key: PreferenceKeys.linkedSQLFolders, store: defaults) + } func loadFolders() -> [LinkedSQLFolder] { - guard let data = UserDefaults.standard.data(forKey: key) else { return [] } - do { - return try JSONDecoder().decode([LinkedSQLFolder].self, from: data) - } catch { - Self.logger.error("Failed to decode linked SQL folders: \(error.localizedDescription, privacy: .public)") - return [] - } + store.load() } func saveFolders(_ folders: [LinkedSQLFolder]) { - do { - let data = try JSONEncoder().encode(folders) - UserDefaults.standard.set(data, forKey: key) - } catch { - Self.logger.error("Failed to encode linked SQL folders: \(error.localizedDescription, privacy: .public)") - } + store.save(folders) } func addFolder(_ folder: LinkedSQLFolder) { - var folders = loadFolders() - folders.append(folder) - saveFolders(folders) + store.add(folder) } func removeFolder(_ folder: LinkedSQLFolder) { - var folders = loadFolders() - folders.removeAll { $0.id == folder.id } - saveFolders(folders) + store.remove(id: folder.id) } func updateFolder(_ folder: LinkedSQLFolder) { - var folders = loadFolders() - guard let index = folders.firstIndex(where: { $0.id == folder.id }) else { return } - folders[index] = folder - saveFolders(folders) + store.update(folder) } } diff --git a/TablePro/Core/Storage/Preferences/CodableListPreferenceStore.swift b/TablePro/Core/Storage/Preferences/CodableListPreferenceStore.swift new file mode 100644 index 000000000..dd4a0799f --- /dev/null +++ b/TablePro/Core/Storage/Preferences/CodableListPreferenceStore.swift @@ -0,0 +1,59 @@ +// +// CodableListPreferenceStore.swift +// TablePro +// + +import Foundation +import os + +final class CodableListPreferenceStore: @unchecked Sendable { + private static var logger: Logger { + Logger(subsystem: "com.TablePro", category: "CodableListPreferenceStore") + } + + private let key: DefaultsKey<[Element]> + private let store: KeyValueStore + + init(key: DefaultsKey<[Element]>, store: KeyValueStore) { + self.key = key + self.store = store + } + + func load() -> [Element] { + guard let data = store.dataValue(forKey: key.name) else { return [] } + do { + return try JSONDecoder().decode([Element].self, from: data) + } catch { + Self.logger.error("Failed to decode \(self.key.name, privacy: .public): \(error.localizedDescription, privacy: .public)") + return [] + } + } + + func save(_ elements: [Element]) { + do { + let data = try JSONEncoder().encode(elements) + store.setDataValue(data, forKey: key.name) + } catch { + Self.logger.error("Failed to encode \(self.key.name, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } + + func add(_ element: Element) { + var elements = load() + elements.append(element) + save(elements) + } + + func remove(id: Element.ID) { + var elements = load() + elements.removeAll { $0.id == id } + save(elements) + } + + func update(_ element: Element) { + var elements = load() + guard let index = elements.firstIndex(where: { $0.id == element.id }) else { return } + elements[index] = element + save(elements) + } +} diff --git a/TablePro/Core/Storage/Preferences/DefaultsKey.swift b/TablePro/Core/Storage/Preferences/DefaultsKey.swift new file mode 100644 index 000000000..09adc3ba2 --- /dev/null +++ b/TablePro/Core/Storage/Preferences/DefaultsKey.swift @@ -0,0 +1,14 @@ +// +// DefaultsKey.swift +// TablePro +// + +import Foundation + +struct DefaultsKey: @unchecked Sendable { + let name: String + + init(_ name: String) { + self.name = name + } +} diff --git a/TablePro/Core/Storage/Preferences/KeyValueStore.swift b/TablePro/Core/Storage/Preferences/KeyValueStore.swift new file mode 100644 index 000000000..15cf32fb4 --- /dev/null +++ b/TablePro/Core/Storage/Preferences/KeyValueStore.swift @@ -0,0 +1,25 @@ +// +// KeyValueStore.swift +// TablePro +// + +import Foundation + +protocol KeyValueStore: AnyObject { + func dataValue(forKey key: String) -> Data? + func setDataValue(_ data: Data?, forKey key: String) +} + +extension UserDefaults: KeyValueStore { + func dataValue(forKey key: String) -> Data? { + data(forKey: key) + } + + func setDataValue(_ data: Data?, forKey key: String) { + guard let data else { + removeObject(forKey: key) + return + } + set(data, forKey: key) + } +} diff --git a/TablePro/Core/Storage/Preferences/PreferenceKeys.swift b/TablePro/Core/Storage/Preferences/PreferenceKeys.swift new file mode 100644 index 000000000..c83825d5d --- /dev/null +++ b/TablePro/Core/Storage/Preferences/PreferenceKeys.swift @@ -0,0 +1,28 @@ +// +// PreferenceKeys.swift +// TablePro +// + +import Foundation + +enum PreferenceKeys { + static let linkedFolders = DefaultsKey<[LinkedFolder]>("com.TablePro.linkedFolders") + static let linkedSQLFolders = DefaultsKey<[LinkedSQLFolder]>("com.TablePro.linkedSQLFolders") + static let selectedSettingsPane = DefaultsKey("com.TablePro.settings.selectedPane") + static let rowInspectorJsonFieldHeight = DefaultsKey("com.TablePro.rightSidebar.jsonFieldHeight") + + static let registeredKeyNames: [String] = [ + linkedFolders.name, + linkedSQLFolders.name, + selectedSettingsPane.name, + rowInspectorJsonFieldHeight.name, + ] + + static func columnDisplayFormats(_ scope: TableScope) -> DefaultsKey<[String: ValueDisplayFormat]> { + DefaultsKey("com.TablePro.columns.displayFormat." + scope.storageComponent) + } + + static func recentTables(connectionId: UUID) -> DefaultsKey<[RecentTableEntry]> { + DefaultsKey("com.TablePro.recentTables." + connectionId.uuidString) + } +} diff --git a/TablePro/Core/Storage/Preferences/TableScope+Decode.swift b/TablePro/Core/Storage/Preferences/TableScope+Decode.swift new file mode 100644 index 000000000..8858bfcd7 --- /dev/null +++ b/TablePro/Core/Storage/Preferences/TableScope+Decode.swift @@ -0,0 +1,31 @@ +// +// TableScope+Decode.swift +// TablePro +// + +import Foundation + +extension TableScope { + init?(storageComponent: String) { + let parts = storageComponent + .split(separator: ".", omittingEmptySubsequences: false) + .map(String.init) + guard parts.count == 4 else { return nil } + + let decoded = parts.map { $0.removingPercentEncoding ?? $0 } + guard let connectionId = UUID(uuidString: decoded[0]) else { return nil } + + self.init( + connectionId: connectionId, + database: decoded[1].isEmpty ? nil : decoded[1], + schema: decoded[2].isEmpty ? nil : decoded[2], + table: decoded[3] + ) + } + + var displayName: String { + [database, schema, table] + .compactMap { $0?.isEmpty == false ? $0 : nil } + .joined(separator: ".") + } +} diff --git a/TablePro/Core/Storage/Preferences/TableScope.swift b/TablePro/Core/Storage/Preferences/TableScope.swift new file mode 100644 index 000000000..f31c43e88 --- /dev/null +++ b/TablePro/Core/Storage/Preferences/TableScope.swift @@ -0,0 +1,26 @@ +// +// TableScope.swift +// TablePro +// + +import Foundation + +struct TableScope: Hashable, Codable, Sendable { + let connectionId: UUID + let database: String? + let schema: String? + let table: String + + init(connectionId: UUID, database: String?, schema: String?, table: String) { + self.connectionId = connectionId + self.database = database + self.schema = schema + self.table = table + } + + var storageComponent: String { + [connectionId.uuidString, database ?? "", schema ?? "", table] + .map { $0.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? $0 } + .joined(separator: ".") + } +} diff --git a/TablePro/Core/Storage/RecentTablesStore.swift b/TablePro/Core/Storage/RecentTablesStore.swift index d8b220b22..e8a828ea3 100644 --- a/TablePro/Core/Storage/RecentTablesStore.swift +++ b/TablePro/Core/Storage/RecentTablesStore.swift @@ -36,15 +36,28 @@ final class RecentTablesStore { static let perDatabaseCap = 10 private let defaults: UserDefaults - private let keyPrefix = "RecentTables.v1." + private let legacyKeyPrefix = "RecentTables.v1." init(defaults: UserDefaults = .standard) { self.defaults = defaults } func entries(connectionId: UUID) -> [RecentTableEntry] { - guard let data = defaults.data(forKey: storageKey(connectionId)) else { return [] } - return (try? JSONDecoder().decode([RecentTableEntry].self, from: data)) ?? [] + if let data = defaults.data(forKey: PreferenceKeys.recentTables(connectionId: connectionId).name) { + return (try? JSONDecoder().decode([RecentTableEntry].self, from: data)) ?? [] + } + return migrateLegacy(connectionId: connectionId) + } + + private func migrateLegacy(connectionId: UUID) -> [RecentTableEntry] { + let legacyKey = legacyKeyPrefix + connectionId.uuidString + guard let data = defaults.data(forKey: legacyKey), + let entries = try? JSONDecoder().decode([RecentTableEntry].self, from: data) else { + return [] + } + persist(entries, connectionId: connectionId) + defaults.removeObject(forKey: legacyKey) + return entries } @discardableResult @@ -86,10 +99,6 @@ final class RecentTablesStore { private func persist(_ entries: [RecentTableEntry], connectionId: UUID) { guard let data = try? JSONEncoder().encode(entries) else { return } - defaults.set(data, forKey: storageKey(connectionId)) - } - - private func storageKey(_ connectionId: UUID) -> String { - keyPrefix + connectionId.uuidString + defaults.set(data, forKey: PreferenceKeys.recentTables(connectionId: connectionId).name) } } diff --git a/TablePro/Core/Storage/SSHProfileStorage.swift b/TablePro/Core/Storage/SSHProfileStorage.swift index 71de5917f..533b8cecc 100644 --- a/TablePro/Core/Storage/SSHProfileStorage.swift +++ b/TablePro/Core/Storage/SSHProfileStorage.swift @@ -148,24 +148,7 @@ final class SSHProfileStorage { } private func resolveString(label: String, profileId: UUID, forKey key: String) -> String? { - let pid = profileId.uuidString - switch keychain.readStringResult(forKey: key) { - case .found(let value): - return value - case .notFound: - return nil - case .locked: - Self.logger.warning("\(label, privacy: .public) unavailable: Keychain locked (profileId=\(pid, privacy: .public))") - return nil - case .userCancelled: - Self.logger.notice("\(label, privacy: .public) prompt cancelled (profileId=\(pid, privacy: .public))") - return nil - case .authFailed: - Self.logger.warning("\(label, privacy: .public) auth failed (profileId=\(pid, privacy: .public))") - return nil - case .error(let status): - Self.logger.error("\(label, privacy: .public) read error \(status) (profileId=\(pid, privacy: .public))") - return nil - } + keychain.readStringResult(forKey: key) + .value(label: "\(label) (profileId=\(profileId.uuidString))", logger: Self.logger) } } diff --git a/TablePro/Core/Storage/SavedCustomizationsService.swift b/TablePro/Core/Storage/SavedCustomizationsService.swift new file mode 100644 index 000000000..74b33e615 --- /dev/null +++ b/TablePro/Core/Storage/SavedCustomizationsService.swift @@ -0,0 +1,64 @@ +// +// SavedCustomizationsService.swift +// TablePro +// + +import Foundation + +struct SavedTableCustomization: Identifiable, Equatable { + let scope: TableScope + let hasLayout: Bool + let hasFilters: Bool + + var id: String { scope.storageComponent } +} + +@MainActor +enum SavedCustomizationsService { + static func all( + layoutStore: FileColumnLayoutPersister = .shared, + filterStore: FilterSettingsStorage = .shared + ) -> [SavedTableCustomization] { + let layoutKeys = Set(layoutStore.customizedStorageKeys()) + let filterKeys = Set(filterStore.customizedStorageKeys()) + + return layoutKeys.union(filterKeys) + .compactMap { key -> SavedTableCustomization? in + guard let scope = TableScope(storageComponent: key) else { return nil } + return SavedTableCustomization( + scope: scope, + hasLayout: layoutKeys.contains(key), + hasFilters: filterKeys.contains(key) + ) + } + .sorted { $0.scope.displayName.localizedStandardCompare($1.scope.displayName) == .orderedAscending } + } + + static func reset( + _ scope: TableScope, + layoutStore: FileColumnLayoutPersister = .shared, + filterStore: FilterSettingsStorage = .shared + ) { + layoutStore.clear(for: ColumnLayoutTableKey( + connectionId: scope.connectionId, + databaseName: scope.database ?? "", + schemaName: scope.schema, + tableName: scope.table + )) + filterStore.clearLastFilters( + for: scope.table, + connectionId: scope.connectionId, + databaseName: scope.database ?? "", + schemaName: scope.schema + ) + } + + static func resetAll( + layoutStore: FileColumnLayoutPersister = .shared, + filterStore: FilterSettingsStorage = .shared + ) { + for customization in all(layoutStore: layoutStore, filterStore: filterStore) { + reset(customization.scope, layoutStore: layoutStore, filterStore: filterStore) + } + } +} diff --git a/TablePro/Core/Storage/ValueDisplayFormatStorage.swift b/TablePro/Core/Storage/ValueDisplayFormatStorage.swift index d49a097c4..bb6eeab70 100644 --- a/TablePro/Core/Storage/ValueDisplayFormatStorage.swift +++ b/TablePro/Core/Storage/ValueDisplayFormatStorage.swift @@ -9,40 +9,54 @@ import Foundation internal final class ValueDisplayFormatStorage { static let shared = ValueDisplayFormatStorage() - private init() {} + private let store: KeyValueStore - // MARK: - Public API + init(defaults: KeyValueStore = UserDefaults.standard) { + store = defaults + } - func save(_ formats: [String: ValueDisplayFormat], for tableName: String, connectionId: UUID) { + func save(_ formats: [String: ValueDisplayFormat], for scope: TableScope) { guard !formats.isEmpty else { - clear(for: tableName, connectionId: connectionId) + clear(for: scope) return } + guard let data = try? JSONEncoder().encode(formats) else { return } + store.setDataValue(data, forKey: PreferenceKeys.columnDisplayFormats(scope).name) + removeLegacy(for: scope) + } - let key = Self.userDefaultsKey(tableName: tableName, connectionId: connectionId) - if let data = try? JSONEncoder().encode(formats) { - UserDefaults.standard.set(data, forKey: key) + func load(for scope: TableScope) -> [String: ValueDisplayFormat]? { + if let data = store.dataValue(forKey: PreferenceKeys.columnDisplayFormats(scope).name), + let formats = try? JSONDecoder().decode([String: ValueDisplayFormat].self, from: data) { + return formats } + return migrateLegacy(for: scope) + } + + func clear(for scope: TableScope) { + store.setDataValue(nil, forKey: PreferenceKeys.columnDisplayFormats(scope).name) + removeLegacy(for: scope) } - func load(for tableName: String, connectionId: UUID) -> [String: ValueDisplayFormat]? { - let key = Self.userDefaultsKey(tableName: tableName, connectionId: connectionId) - guard let data = UserDefaults.standard.data(forKey: key), - let formats = try? JSONDecoder().decode([String: ValueDisplayFormat].self, from: data) - else { + private func migrateLegacy(for scope: TableScope) -> [String: ValueDisplayFormat]? { + let legacyKey = Self.legacyKey(for: scope) + guard let data = store.dataValue(forKey: legacyKey), + let formats = try? JSONDecoder().decode([String: ValueDisplayFormat].self, from: data), + !formats.isEmpty else { return nil } + if let encoded = try? JSONEncoder().encode(formats) { + store.setDataValue(encoded, forKey: PreferenceKeys.columnDisplayFormats(scope).name) + } + store.setDataValue(nil, forKey: legacyKey) return formats } - func clear(for tableName: String, connectionId: UUID) { - let key = Self.userDefaultsKey(tableName: tableName, connectionId: connectionId) - UserDefaults.standard.removeObject(forKey: key) + private func removeLegacy(for scope: TableScope) { + store.setDataValue(nil, forKey: Self.legacyKey(for: scope)) } - // MARK: - Private - - private static func userDefaultsKey(tableName: String, connectionId: UUID) -> String { - "com.TablePro.columns.displayFormat.\(connectionId.uuidString).\(tableName)" + private static func legacyKey(for scope: TableScope) -> String { + "com.TablePro.columns.displayFormat.\(scope.connectionId.uuidString).\(scope.table)" } } diff --git a/TablePro/Core/Sync/SyncChangeTracker.swift b/TablePro/Core/Sync/SyncChangeTracker.swift index ba838bf75..a5edd284d 100644 --- a/TablePro/Core/Sync/SyncChangeTracker.swift +++ b/TablePro/Core/Sync/SyncChangeTracker.swift @@ -31,14 +31,14 @@ final class SyncChangeTracker { // MARK: - Mark Dirty func markDirty(_ type: SyncRecordType, id: String) { - guard !isSuppressed else { return } + guard !isSuppressed, type.syncScope == .synced else { return } metadataStorage.addDirty(type: type, id: id) Self.logger.info("Marked dirty: \(type.rawValue)/\(id)") postChangeNotification() } func markDirty(_ type: SyncRecordType, ids: [String]) { - guard !isSuppressed, !ids.isEmpty else { return } + guard !isSuppressed, !ids.isEmpty, type.syncScope == .synced else { return } for id in ids { metadataStorage.addDirty(type: type, id: id) } diff --git a/TablePro/Core/Sync/SyncCoordinator.swift b/TablePro/Core/Sync/SyncCoordinator.swift index 60cfd96bf..712018216 100644 --- a/TablePro/Core/Sync/SyncCoordinator.swift +++ b/TablePro/Core/Sync/SyncCoordinator.swift @@ -191,6 +191,10 @@ final class SyncCoordinator { changeTracker.markDirty(.settings, id: category) } + for storageKey in FileColumnLayoutPersister.shared.customizedStorageKeys() { + changeTracker.markDirty(.settings, id: FileColumnLayoutPersister.syncCategory(for: storageKey)) + } + let summary = [ "connections=\(connections.count)", "groups=\(groups.count)", @@ -864,6 +868,10 @@ final class SyncCoordinator { case "ai": return try encoder.encode(storage.loadAI()) case CustomSlashCommandStorage.syncCategory: return try encoder.encode(CustomSlashCommandStorage.shared.commands) + case let category where category.hasPrefix(FileColumnLayoutPersister.syncCategoryPrefix): + return FileColumnLayoutPersister.shared.rawData( + forStorageKey: String(category.dropFirst(FileColumnLayoutPersister.syncCategoryPrefix.count)) + ) default: return nil } } catch { @@ -888,6 +896,11 @@ final class SyncCoordinator { case "ai": manager.ai = try decoder.decode(AISettings.self, from: data) case CustomSlashCommandStorage.syncCategory: CustomSlashCommandStorage.shared.applyRemote(try decoder.decode([CustomSlashCommand].self, from: data)) + case let category where category.hasPrefix(FileColumnLayoutPersister.syncCategoryPrefix): + FileColumnLayoutPersister.shared.applyRemote( + storageKey: String(category.dropFirst(FileColumnLayoutPersister.syncCategoryPrefix.count)), + data: data + ) default: return } } catch { diff --git a/TablePro/Core/Sync/SyncScope.swift b/TablePro/Core/Sync/SyncScope.swift new file mode 100644 index 000000000..3cce14ada --- /dev/null +++ b/TablePro/Core/Sync/SyncScope.swift @@ -0,0 +1,20 @@ +// +// SyncScope.swift +// TablePro +// + +import Foundation + +enum SyncScope: Equatable { + case synced + case deviceLocal +} + +extension SyncRecordType { + var syncScope: SyncScope { + switch self { + case .connection, .group, .tag, .settings, .favorite, .favoriteFolder, .tableFavorite, .sshProfile: + return .synced + } + } +} diff --git a/TablePro/Models/Query/TabTableContext+Scope.swift b/TablePro/Models/Query/TabTableContext+Scope.swift new file mode 100644 index 000000000..c6dff6a13 --- /dev/null +++ b/TablePro/Models/Query/TabTableContext+Scope.swift @@ -0,0 +1,14 @@ +// +// TabTableContext+Scope.swift +// TablePro +// + +import Foundation + +extension TabTableContext { + func scope(connectionId: UUID?) -> TableScope? { + guard let connectionId, let tableName else { return nil } + let database = databaseName.isEmpty ? nil : databaseName + return TableScope(connectionId: connectionId, database: database, schema: schemaName, table: tableName) + } +} diff --git a/TablePro/Models/UI/FilterState.swift b/TablePro/Models/UI/FilterState.swift index 22f4a71b8..86dd5f839 100644 --- a/TablePro/Models/UI/FilterState.swift +++ b/TablePro/Models/UI/FilterState.swift @@ -33,6 +33,32 @@ struct BrowseSearchState: Codable, Equatable { } } +struct PersistedFilterState: Codable, Equatable { + var filters: [TableFilter] + var logicMode: FilterLogicMode + + init(filters: [TableFilter], logicMode: FilterLogicMode = .and) { + self.filters = filters + self.logicMode = logicMode + } + + init(from decoder: Decoder) throws { + if let keyed = try? decoder.container(keyedBy: CodingKeys.self), + let filters = try? keyed.decode([TableFilter].self, forKey: .filters) { + self.filters = filters + self.logicMode = (try? keyed.decode(FilterLogicMode.self, forKey: .logicMode)) ?? .and + return + } + let single = try decoder.singleValueContainer() + self.filters = try single.decode([TableFilter].self) + self.logicMode = .and + } + + private enum CodingKeys: String, CodingKey { + case filters, logicMode + } +} + extension TabFilterState { init(filters: [TableFilter], commit: FilterCommit?, isVisible: Bool, filterLogicMode: FilterLogicMode) { self.filters = filters diff --git a/TablePro/Models/UI/WindowSidebarState.swift b/TablePro/Models/UI/WindowSidebarState.swift index 877f97c2b..6670179e0 100644 --- a/TablePro/Models/UI/WindowSidebarState.swift +++ b/TablePro/Models/UI/WindowSidebarState.swift @@ -7,7 +7,7 @@ import Foundation import Observation import TableProPluginKit -struct DatabaseSchemaKey: Hashable, Sendable { +struct DatabaseSchemaKey: Hashable, Sendable, Codable { let database: String let schema: String } @@ -15,8 +15,56 @@ struct DatabaseSchemaKey: Hashable, Sendable { @MainActor @Observable internal final class WindowSidebarState { + @ObservationIgnored private let connectionId: UUID? + @ObservationIgnored private let defaults: UserDefaults + @ObservationIgnored private var isLoaded = false + var selectedTables: Set = [] - var expandedTreeSchemas: Set = [] - var expandedTreeDatabases: Set = [] - var expandedTreeDatabaseSchemas: Set = [] + var expandedTreeSchemas: Set = [] { didSet { persistExpansion() } } + var expandedTreeDatabases: Set = [] { didSet { persistExpansion() } } + var expandedTreeDatabaseSchemas: Set = [] { didSet { persistExpansion() } } + + init(connectionId: UUID? = nil, defaults: UserDefaults = .standard) { + self.connectionId = connectionId + self.defaults = defaults + loadExpansion() + isLoaded = true + } + + private struct PersistedExpansion: Codable { + var schemas: [String] + var databases: [String] + var databaseSchemas: [DatabaseSchemaKey] + } + + private var storageKey: String? { + connectionId.map { "com.TablePro.sidebar.treeExpansion.\($0.uuidString)" } + } + + private func loadExpansion() { + guard let storageKey, + let data = defaults.data(forKey: storageKey), + let decoded = try? JSONDecoder().decode(PersistedExpansion.self, from: data) else { return } + expandedTreeSchemas = Set(decoded.schemas) + expandedTreeDatabases = Set(decoded.databases) + expandedTreeDatabaseSchemas = Set(decoded.databaseSchemas) + } + + private func persistExpansion() { + guard isLoaded, let storageKey else { return } + + if expandedTreeSchemas.isEmpty, expandedTreeDatabases.isEmpty, expandedTreeDatabaseSchemas.isEmpty { + defaults.removeObject(forKey: storageKey) + return + } + + let snapshot = PersistedExpansion( + schemas: Array(expandedTreeSchemas), + databases: Array(expandedTreeDatabases), + databaseSchemas: Array(expandedTreeDatabaseSchemas) + ) + if let data = try? JSONEncoder().encode(snapshot) { + defaults.set(data, forKey: storageKey) + } + } } diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index 1d1959143..2b2cab867 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -816,6 +816,7 @@ struct TableProApp: App { init() { AppSettingsStorage.shared.migrateStartupBehaviorToReopenLastIfNeeded() + AppSettingsStorage.shared.migrateJsonFieldHeightKeyIfNeeded() AIProviderRegistration.registerAll() // Perform startup cleanup of query history if auto-cleanup is enabled diff --git a/TablePro/Theme/ResolvedThemeColors.swift b/TablePro/Theme/ResolvedThemeColors.swift index 4917b4eea..59336dcc1 100644 --- a/TablePro/Theme/ResolvedThemeColors.swift +++ b/TablePro/Theme/ResolvedThemeColors.swift @@ -150,9 +150,6 @@ struct ResolvedUIColors { let tertiaryText: NSColor let tertiaryTextSwiftUI: Color - let accentColor: NSColor? - let accentColorSwiftUI: Color? - let selectionBackground: NSColor let selectionBackgroundSwiftUI: Color let hoverBackground: NSColor @@ -192,14 +189,6 @@ struct ResolvedUIColors { tertiaryText = colors.tertiaryText?.nsColor ?? .tertiaryLabelColor tertiaryTextSwiftUI = colors.tertiaryText?.swiftUIColor ?? Color(nsColor: .tertiaryLabelColor) - if let accent = colors.accentColor { - accentColor = accent.nsColor - accentColorSwiftUI = accent.swiftUIColor - } else { - accentColor = nil - accentColorSwiftUI = nil - } - selectionBackground = colors.selectionBackground?.nsColor ?? .selectedContentBackgroundColor selectionBackgroundSwiftUI = colors.selectionBackground?.swiftUIColor ?? Color(nsColor: .selectedContentBackgroundColor) diff --git a/TablePro/Theme/ThemeColors.swift b/TablePro/Theme/ThemeColors.swift index 90ec9013a..65f3a9250 100644 --- a/TablePro/Theme/ThemeColors.swift +++ b/TablePro/Theme/ThemeColors.swift @@ -278,7 +278,6 @@ internal struct UIThemeColors: Codable, Equatable, Sendable { var primaryText: String? var secondaryText: String? var tertiaryText: String? - var accentColor: String? var selectionBackground: String? var hoverBackground: String? var status: StatusColors @@ -292,7 +291,6 @@ internal struct UIThemeColors: Codable, Equatable, Sendable { primaryText: nil, secondaryText: nil, tertiaryText: nil, - accentColor: nil, selectionBackground: nil, hoverBackground: nil, status: .defaultLight, @@ -307,7 +305,6 @@ internal struct UIThemeColors: Codable, Equatable, Sendable { primaryText: String?, secondaryText: String?, tertiaryText: String?, - accentColor: String?, selectionBackground: String?, hoverBackground: String?, status: StatusColors, @@ -320,7 +317,6 @@ internal struct UIThemeColors: Codable, Equatable, Sendable { self.primaryText = primaryText self.secondaryText = secondaryText self.tertiaryText = tertiaryText - self.accentColor = accentColor self.selectionBackground = selectionBackground self.hoverBackground = hoverBackground self.status = status @@ -338,7 +334,6 @@ internal struct UIThemeColors: Codable, Equatable, Sendable { primaryText = try container.decodeIfPresent(String.self, forKey: .primaryText) secondaryText = try container.decodeIfPresent(String.self, forKey: .secondaryText) tertiaryText = try container.decodeIfPresent(String.self, forKey: .tertiaryText) - accentColor = try container.decodeIfPresent(String.self, forKey: .accentColor) selectionBackground = try container.decodeIfPresent(String.self, forKey: .selectionBackground) hoverBackground = try container.decodeIfPresent(String.self, forKey: .hoverBackground) status = try container.decodeIfPresent(StatusColors.self, forKey: .status) ?? fallback.status diff --git a/TablePro/ViewModels/SidebarViewModel.swift b/TablePro/ViewModels/SidebarViewModel.swift index aaa775b8f..c2c4617aa 100644 --- a/TablePro/ViewModels/SidebarViewModel.swift +++ b/TablePro/ViewModels/SidebarViewModel.swift @@ -78,8 +78,13 @@ final class SidebarViewModel { // MARK: - Published State - var searchText = "" { - didSet { scheduleFilterQueryUpdate(oldValue: oldValue) } + var searchText: String { + get { sharedState.searchText } + set { + let oldValue = sharedState.searchText + sharedState.searchText = newValue + scheduleFilterQueryUpdate(oldValue: oldValue) + } } private(set) var filterQuery = "" { @@ -107,7 +112,10 @@ final class SidebarViewModel { ) } } - var redisKeyTreeViewModel: RedisKeyTreeViewModel? + var redisKeyTreeViewModel: RedisKeyTreeViewModel? { + get { sharedState.redisKeyTreeViewModel } + set { sharedState.redisKeyTreeViewModel = newValue } + } var showOperationDialog = false var pendingOperationType: TableOperationType? var pendingOperationTables: [String] = [] @@ -124,6 +132,10 @@ final class SidebarViewModel { private let connectionId: UUID + /// The single connection-scoped state holder. Search text and the Redis key + /// tree live here so this view model and the sidebar views share one source. + @ObservationIgnored let sharedState: SharedSidebarState + // MARK: - Convenience Accessors var selectedTables: Set { @@ -167,6 +179,7 @@ final class SidebarViewModel { self.tableOperationOptionsBinding = tableOperationOptions self.databaseType = databaseType self.connectionId = connectionId + self.sharedState = SharedSidebarState.forConnection(connectionId) self.expanded = Self.loadInitialExpansion(connectionId: connectionId) self.isRedisKeysExpanded = Self.loadExpansion( perConnectionKey: SidebarPersistenceKey.redisKeysExpanded(connectionId: connectionId), diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index ef0c113e5..7578f7e5f 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -732,22 +732,19 @@ struct MainEditorContentView: View { autoMap[columns[i]] = format } } - service.setAutoDetectedFormats(autoMap, connectionId: connectionId, tableName: tab.tableContext.tableName) + service.setAutoDetectedFormats(autoMap, scope: tab.tableContext.scope(connectionId: connectionId)) } else { - service.clearAutoDetectedFormats(connectionId: connectionId, tableName: tab.tableContext.tableName) + service.clearAutoDetectedFormats(scope: tab.tableContext.scope(connectionId: connectionId)) } - let connId = connectionId - let tblName = tab.tableContext.tableName var merged = detected - if let tblName { - if let overrides = ValueDisplayFormatStorage.shared.load(for: tblName, connectionId: connId) { - for (i, colName) in columns.enumerated() { - if let overrideFormat = overrides[colName] { - while merged.count <= i { merged.append(nil) } - merged[i] = overrideFormat - } + if let scope = tab.tableContext.scope(connectionId: connectionId), + let overrides = ValueDisplayFormatStorage.shared.load(for: scope) { + for (i, colName) in columns.enumerated() { + if let overrideFormat = overrides[colName] { + while merged.count <= i { merged.append(nil) } + merged[i] = overrideFormat } } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnVisibility.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnVisibility.swift index e379c9c41..1517ff168 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnVisibility.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnVisibility.swift @@ -55,7 +55,7 @@ extension MainContentCoordinator { func restoreLastHiddenColumnsForTable() { guard let tab = tabManager.selectedTab, let key = columnLayoutTableKey(for: tab) else { return } - let restored = ColumnVisibilityPersistence.loadHiddenColumns(for: key) + let restored = FileColumnLayoutPersister.shared.loadHiddenColumns(for: key) mutateSelectedTabHiddenColumns(persist: false) { $0 = restored } } @@ -75,7 +75,6 @@ extension MainContentCoordinator { let tab = tabManager.tabs[index] if let key = columnLayoutTableKey(for: tab) { FileColumnLayoutPersister.shared.clear(for: key) - ColumnVisibilityPersistence.saveHiddenColumns([], for: key) } tabManager.mutate(at: index) { $0.columnLayout = ColumnLayoutState() } tabSessionRegistry.session(for: tab.id)?.columnLayout = ColumnLayoutState() @@ -102,7 +101,7 @@ extension MainContentCoordinator { private func persistTabHiddenColumns(_ tab: QueryTab) { guard tab.tabType == .table, let key = columnLayoutTableKey(for: tab) else { return } - ColumnVisibilityPersistence.saveHiddenColumns(tab.columnLayout.hiddenColumns, for: key) + FileColumnLayoutPersister.shared.saveHiddenColumns(tab.columnLayout.hiddenColumns, for: key) } private func mutateSelectedTabHiddenColumns(persist: Bool = true, _ mutate: (inout Set) -> Void) { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift index c16542f5e..74f0fa12b 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift @@ -38,6 +38,7 @@ extension MainContentCoordinator { if let tableName = tabManager.tabs[oldIndex].tableContext.tableName { FilterSettingsStorage.shared.saveLastFilters( tabManager.tabs[oldIndex].filterState.appliedFilters, + logicMode: tabManager.tabs[oldIndex].filterState.filterLogicMode, for: tableName, connectionId: connectionId, databaseName: tabManager.tabs[oldIndex].tableContext.databaseName, diff --git a/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift b/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift index 54d1f7eec..4597bd44a 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift @@ -27,7 +27,6 @@ extension MainContentView { let service = ValueDisplayFormatService.shared let connId = coordinator.connection.id - let tblName = tab.tableContext.tableName for (i, col) in tableRows.columns.enumerated() { var value: String? @@ -44,7 +43,7 @@ extension MainContentView { let type = i < tableRows.columnTypes.count ? tableRows.columnTypes[i].displayName : "string" if let rawValue = value { - let format = service.effectiveFormat(columnName: col, connectionId: connId, tableName: tblName) + let format = service.effectiveFormat(columnName: col, scope: tab.tableContext.scope(connectionId: connId)) if format != .raw { value = ValueDisplayFormatService.applyFormat(rawValue, format: format) } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 0722b3e31..a7ba1861b 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -90,7 +90,7 @@ final class MainContentCoordinator { let toolbarState: ConnectionToolbarState let tabSessionRegistry: TabSessionRegistry let queryExecutor: QueryExecutor - let windowSidebarState = WindowSidebarState() + let windowSidebarState: WindowSidebarState // MARK: - Services @@ -434,6 +434,7 @@ final class MainContentCoordinator { let initStart = Date() self.services = services self.connection = connection + self.windowSidebarState = WindowSidebarState(connectionId: connection.id) self.tabManager = tabManager self.changeManager = changeManager self.toolbarState = toolbarState diff --git a/TablePro/Views/Results/DataGridCoordinator+Scope.swift b/TablePro/Views/Results/DataGridCoordinator+Scope.swift new file mode 100644 index 000000000..609c1f5a1 --- /dev/null +++ b/TablePro/Views/Results/DataGridCoordinator+Scope.swift @@ -0,0 +1,13 @@ +// +// DataGridCoordinator+Scope.swift +// TablePro +// + +import Foundation + +extension TableViewCoordinator { + var tableScope: TableScope? { + guard let connectionId, let tableName else { return nil } + return TableScope(connectionId: connectionId, database: databaseName, schema: schemaName, table: tableName) + } +} diff --git a/TablePro/Views/Results/Extensions/DataGridView+Click.swift b/TablePro/Views/Results/Extensions/DataGridView+Click.swift index 6a269203c..8e42fc57a 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Click.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Click.swift @@ -44,8 +44,7 @@ extension TableViewCoordinator { let immutable = databaseType.map { PluginManager.shared.immutableColumns(for: $0) } ?? [] let override = ValueDisplayFormatService.shared.effectiveFormat( columnName: columnName, - connectionId: connectionId, - tableName: tableName + scope: tableScope ) return CellContext( diff --git a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift index 2b2aecdbe..bbc488d03 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift @@ -172,8 +172,7 @@ extension TableViewCoordinator { let displaySubmenu = NSMenu() let currentFormat = ValueDisplayFormatService.shared.effectiveFormat( columnName: baseName, - connectionId: connectionId, - tableName: tableName + scope: tableScope ) for format in applicableFormats { let item = NSMenuItem( @@ -336,12 +335,11 @@ extension TableViewCoordinator { let formatToStore: ValueDisplayFormat? = (info.format == .raw) ? nil : info.format - if let connId = connectionId, let table = tableName { + if let scope = tableScope { ValueDisplayFormatService.shared.setOverride( formatToStore, columnName: info.columnName, - connectionId: connId, - tableName: table + scope: scope ) } diff --git a/TablePro/Views/RightSidebar/FieldEditors/JsonEditorView.swift b/TablePro/Views/RightSidebar/FieldEditors/JsonEditorView.swift index 541dcf7e3..62b2eff69 100644 --- a/TablePro/Views/RightSidebar/FieldEditors/JsonEditorView.swift +++ b/TablePro/Views/RightSidebar/FieldEditors/JsonEditorView.swift @@ -11,7 +11,8 @@ internal struct JsonEditorView: View { var onPopOut: ((String) -> Void)? @State private var displayText: String - @AppStorage("rightSidebar.jsonFieldHeight") private var fieldHeight = ResizableFieldMetrics.defaultJsonHeight + @AppStorage(PreferenceKeys.rowInspectorJsonFieldHeight.name) private var fieldHeight = ResizableFieldMetrics + .defaultJsonHeight init(context: FieldEditorContext, onExpand: (() -> Void)? = nil, onPopOut: ((String) -> Void)? = nil) { self.context = context diff --git a/TablePro/Views/Settings/AppearanceSettingsView.swift b/TablePro/Views/Settings/AppearanceSettingsView.swift index 9597aa77b..d6d9a5990 100644 --- a/TablePro/Views/Settings/AppearanceSettingsView.swift +++ b/TablePro/Views/Settings/AppearanceSettingsView.swift @@ -2,47 +2,38 @@ // AppearanceSettingsView.swift // TablePro // -// Settings for theme browsing, customization, and accent color. +// Settings for theme browsing and customization. // import SwiftUI +enum ThemeEditSlot: Hashable { + case light + case dark +} + struct AppearanceSettingsView: View { @Binding var settings: AppearanceSettings + @State private var chosenSlot: ThemeEditSlot? + + /// The slot currently being edited. Defaults to the active appearance so the + /// pane opens on the theme in use, but the user can switch to edit the other + /// slot without changing the app's appearance mode. + private var editSlot: ThemeEditSlot { + chosenSlot ?? (ThemeEngine.shared.effectiveAppearance == .dark ? .dark : .light) + } - /// Computed binding that reads/writes the correct preferred theme slot. - /// On read: returns the theme for the current effective appearance. - /// On write: uses the selected theme's appearance metadata to determine the correct slot, - /// and switches the appearance mode so the user sees the change immediately. - private var effectiveThemeIdBinding: Binding { + private var slotThemeBinding: Binding { Binding( get: { - ThemeEngine.shared.effectiveAppearance == .dark - ? settings.preferredDarkThemeId - : settings.preferredLightThemeId + editSlot == .dark ? settings.preferredDarkThemeId : settings.preferredLightThemeId }, set: { newId in - guard let theme = ThemeEngine.shared.availableThemes - .first(where: { $0.id == newId }) else { return } - - // Assign to the correct slot based on the theme's appearance and - // switch mode to match so the user sees the change immediately. - // Mutate a local copy so didSet fires only once. var updated = settings - switch theme.appearance { - case .dark: + if editSlot == .dark { updated.preferredDarkThemeId = newId - updated.appearanceMode = .dark - case .light: + } else { updated.preferredLightThemeId = newId - updated.appearanceMode = .light - case .auto: - updated.appearanceMode = .auto - if ThemeEngine.shared.effectiveAppearance == .dark { - updated.preferredDarkThemeId = newId - } else { - updated.preferredLightThemeId = newId - } } settings = updated } @@ -65,6 +56,17 @@ struct AppearanceSettingsView: View { .fixedSize() Spacer() + + Text("Editing") + .font(.callout) + .foregroundStyle(.secondary) + + Picker("", selection: Binding(get: { editSlot }, set: { chosenSlot = $0 })) { + Text("Light").tag(ThemeEditSlot.light) + Text("Dark").tag(ThemeEditSlot.dark) + } + .pickerStyle(.segmented) + .fixedSize() } .padding(.horizontal, 16) .padding(.vertical, 8) @@ -72,10 +74,10 @@ struct AppearanceSettingsView: View { Divider() HSplitView { - ThemeListView(selectedThemeId: effectiveThemeIdBinding) + ThemeListView(selectedThemeId: slotThemeBinding) .frame(minWidth: 180, idealWidth: 210, maxWidth: 250) - ThemeEditorView(selectedThemeId: effectiveThemeIdBinding) + ThemeEditorView(selectedThemeId: slotThemeBinding) .frame(minWidth: 400) } } diff --git a/TablePro/Views/Settings/DataResultsSettingsView.swift b/TablePro/Views/Settings/DataResultsSettingsView.swift new file mode 100644 index 000000000..d695d7e07 --- /dev/null +++ b/TablePro/Views/Settings/DataResultsSettingsView.swift @@ -0,0 +1,40 @@ +// +// DataResultsSettingsView.swift +// TablePro +// + +import SwiftUI + +struct DataResultsSettingsView: View { + @Binding var dataGrid: DataGridSettings + @Binding var history: HistorySettings + @Binding var editor: EditorSettings + + var body: some View { + Form { + DataGridSection(settings: $dataGrid) + + Section("JSON Viewer") { + Picker("Default view:", selection: $editor.jsonViewerPreferredMode) { + Text("Text").tag(JSONViewMode.text) + Text("Tree").tag(JSONViewMode.tree) + } + } + + HistorySection(settings: $history) + + SavedCustomizationsSection() + } + .formStyle(.grouped) + .scrollContentBackground(.hidden) + } +} + +#Preview { + DataResultsSettingsView( + dataGrid: .constant(.default), + history: .constant(.default), + editor: .constant(.default) + ) + .frame(width: 450, height: 500) +} diff --git a/TablePro/Views/Settings/EditorSettingsView.swift b/TablePro/Views/Settings/EditorSettingsView.swift index 249fbd7ec..c06bfff95 100644 --- a/TablePro/Views/Settings/EditorSettingsView.swift +++ b/TablePro/Views/Settings/EditorSettingsView.swift @@ -7,7 +7,6 @@ import SwiftUI struct EditorSettingsView: View { @Binding var settings: EditorSettings - @Binding var dataGridSettings: DataGridSettings var body: some View { Form { @@ -24,15 +23,6 @@ struct EditorSettingsView: View { Toggle("Query parameters (:name syntax)", isOn: $settings.queryParametersEnabled) Toggle("Vim mode", isOn: $settings.vimModeEnabled) } - - Section("JSON Viewer") { - Picker("Default view:", selection: $settings.jsonViewerPreferredMode) { - Text("Text").tag(JSONViewMode.text) - Text("Tree").tag(JSONViewMode.tree) - } - } - - DataGridSection(settings: $dataGridSettings) } .formStyle(.grouped) .scrollContentBackground(.hidden) @@ -40,6 +30,6 @@ struct EditorSettingsView: View { } #Preview { - EditorSettingsView(settings: .constant(.default), dataGridSettings: .constant(.default)) + EditorSettingsView(settings: .constant(.default)) .frame(width: 450, height: 500) } diff --git a/TablePro/Views/Settings/GeneralSettingsView.swift b/TablePro/Views/Settings/GeneralSettingsView.swift index 356a7cd84..45a623dea 100644 --- a/TablePro/Views/Settings/GeneralSettingsView.swift +++ b/TablePro/Views/Settings/GeneralSettingsView.swift @@ -9,7 +9,6 @@ import SwiftUI struct GeneralSettingsView: View { @Binding var settings: GeneralSettings @Binding var tabSettings: TabSettings - @Binding var historySettings: HistorySettings var updaterBridge: UpdaterBridge var onResetAll: () -> Void @@ -59,6 +58,9 @@ struct GeneralSettingsView: View { Toggle("Show recent tables", isOn: $settings.showRecentTables) .help("Adds a Recent section at the top of the Tables sidebar with the last tables you opened per connection and database.") + Toggle("Show object comments", isOn: $settings.showObjectComments) + .help("Shows database object comments next to tables in the sidebar and in grid column headers.") + Picker("Default layout for new connections:", selection: $defaultSidebarLayout) { Text("List").tag(SidebarLayout.flat) Text("Tree").tag(SidebarLayout.tree) @@ -76,8 +78,6 @@ struct GeneralSettingsView: View { .help(String(localized: "Maximum time to wait for a query to complete. Set to 0 for no limit. Applied to new connections.")) } - HistorySection(settings: $historySettings) - CommandLineToolSection() TrustedExternalConnectionsSection() @@ -127,7 +127,6 @@ struct GeneralSettingsView: View { GeneralSettingsView( settings: .constant(.default), tabSettings: .constant(.default), - historySettings: .constant(.default), updaterBridge: UpdaterBridge.shared, onResetAll: {} ) diff --git a/TablePro/Views/Settings/SavedCustomizationsSection.swift b/TablePro/Views/Settings/SavedCustomizationsSection.swift new file mode 100644 index 000000000..d4f18e202 --- /dev/null +++ b/TablePro/Views/Settings/SavedCustomizationsSection.swift @@ -0,0 +1,71 @@ +// +// SavedCustomizationsSection.swift +// TablePro +// + +import SwiftUI + +struct SavedCustomizationsSection: View { + @State private var items: [SavedTableCustomization] = [] + + var body: some View { + Group { + if items.isEmpty { + Section { + Text("No saved customizations yet. Column widths, order, visibility, and per-table filters you set appear here so you can review and reset them.") + .foregroundStyle(.secondary) + } header: { + Text("Saved Customizations") + } + } else { + Section { + ForEach(items) { item in + row(item) + } + } header: { + Text("Saved Customizations") + } footer: { + Text("You edit these inline in the grid and filter bar. Reset a table here to return it to defaults.") + } + + Section { + Button("Reset All Customizations", role: .destructive) { + SavedCustomizationsService.resetAll() + reload() + } + } + } + } + .onAppear(perform: reload) + } + + @ViewBuilder + private func row(_ item: SavedTableCustomization) -> some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(item.scope.displayName) + Text(summary(item)) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Button("Reset") { + SavedCustomizationsService.reset(item.scope) + reload() + } + } + } + + private func summary(_ item: SavedTableCustomization) -> String { + var parts: [String] = [] + if item.hasLayout { parts.append(String(localized: "Columns")) } + if item.hasFilters { parts.append(String(localized: "Filters")) } + return parts.joined(separator: " · ") + } + + private func reload() { + items = SavedCustomizationsService.all() + } +} diff --git a/TablePro/Views/Settings/SettingsView.swift b/TablePro/Views/Settings/SettingsView.swift index efe8b3b0b..01e46fb05 100644 --- a/TablePro/Views/Settings/SettingsView.swift +++ b/TablePro/Views/Settings/SettingsView.swift @@ -5,63 +5,102 @@ import SwiftUI -enum SettingsTab: String { - case general, appearance, editor, keyboard, ai, mcp, plugins, account +enum SettingsPane: String { + case general, appearance, editor, data, keyboard, ai, mcp, plugins, account + + var title: String { + switch self { + case .general: String(localized: "General") + case .appearance: String(localized: "Appearance") + case .editor: String(localized: "Editor") + case .data: String(localized: "Data") + case .keyboard: String(localized: "Keyboard") + case .ai: String(localized: "AI") + case .mcp: String(localized: "Integrations") + case .plugins: String(localized: "Plugins") + case .account: String(localized: "Account") + } + } + + var symbol: String { + switch self { + case .general: "gearshape" + case .appearance: "paintbrush" + case .editor: "doc.text" + case .data: "tablecells" + case .keyboard: "keyboard" + case .ai: "sparkles" + case .mcp: "network" + case .plugins: "puzzlepiece.extension" + case .account: "person.crop.circle" + } + } } struct SettingsView: View { @Bindable private var settingsManager = AppSettingsManager.shared @Environment(UpdaterBridge.self) var updaterBridge - @AppStorage("selectedSettingsTab") private var selectedTab: String = SettingsTab.general.rawValue + @AppStorage(PreferenceKeys.selectedSettingsPane.name) private var selectedTab = SettingsPane.general.rawValue private let pluginManager = PluginManager.shared private var pluginAttentionCount: Int { pluginManager.rejectedPlugins.count + pluginManager.pluginsWithRegistryUpdate.count } + private var selection: Binding { + Binding( + get: { SettingsPane(rawValue: selectedTab) == nil ? SettingsPane.general.rawValue : selectedTab }, + set: { selectedTab = $0 } + ) + } + var body: some View { - TabView(selection: $selectedTab) { + TabView(selection: selection) { GeneralSettingsView( settings: $settingsManager.general, tabSettings: $settingsManager.tabs, - historySettings: $settingsManager.history, updaterBridge: updaterBridge, onResetAll: { settingsManager.resetToDefaults() } ) - .tabItem { Label("General", systemImage: "gearshape") } - .tag(SettingsTab.general.rawValue) + .tabItem { Label(SettingsPane.general.title, systemImage: SettingsPane.general.symbol) } + .tag(SettingsPane.general.rawValue) AppearanceSettingsView(settings: $settingsManager.appearance) - .tabItem { Label("Appearance", systemImage: "paintbrush") } - .tag(SettingsTab.appearance.rawValue) + .tabItem { Label(SettingsPane.appearance.title, systemImage: SettingsPane.appearance.symbol) } + .tag(SettingsPane.appearance.rawValue) + + EditorSettingsView(settings: $settingsManager.editor) + .tabItem { Label(SettingsPane.editor.title, systemImage: SettingsPane.editor.symbol) } + .tag(SettingsPane.editor.rawValue) - EditorSettingsView( - settings: $settingsManager.editor, - dataGridSettings: $settingsManager.dataGrid + DataResultsSettingsView( + dataGrid: $settingsManager.dataGrid, + history: $settingsManager.history, + editor: $settingsManager.editor ) - .tabItem { Label("Editor", systemImage: "doc.text") } - .tag(SettingsTab.editor.rawValue) + .tabItem { Label(SettingsPane.data.title, systemImage: SettingsPane.data.symbol) } + .tag(SettingsPane.data.rawValue) KeyboardSettingsView(settings: $settingsManager.keyboard) - .tabItem { Label("Keyboard", systemImage: "keyboard") } - .tag(SettingsTab.keyboard.rawValue) + .tabItem { Label(SettingsPane.keyboard.title, systemImage: SettingsPane.keyboard.symbol) } + .tag(SettingsPane.keyboard.rawValue) AISettingsView(settings: $settingsManager.ai) - .tabItem { Label("AI", systemImage: "sparkles") } - .tag(SettingsTab.ai.rawValue) + .tabItem { Label(SettingsPane.ai.title, systemImage: SettingsPane.ai.symbol) } + .tag(SettingsPane.ai.rawValue) MCPSettingsView(settings: $settingsManager.mcp) - .tabItem { Label("Integrations", systemImage: "network") } - .tag(SettingsTab.mcp.rawValue) + .tabItem { Label(SettingsPane.mcp.title, systemImage: SettingsPane.mcp.symbol) } + .tag(SettingsPane.mcp.rawValue) PluginsSettingsView() - .tabItem { Label("Plugins", systemImage: "puzzlepiece.extension") } + .tabItem { Label(SettingsPane.plugins.title, systemImage: SettingsPane.plugins.symbol) } .badge(pluginAttentionCount) - .tag(SettingsTab.plugins.rawValue) + .tag(SettingsPane.plugins.rawValue) AccountSettingsView() - .tabItem { Label("Account", systemImage: "person.crop.circle") } - .tag(SettingsTab.account.rawValue) + .tabItem { Label(SettingsPane.account.title, systemImage: SettingsPane.account.symbol) } + .tag(SettingsPane.account.rawValue) } .frame(width: 720, height: 500) } diff --git a/TableProTests/Core/Database/SQLBoundaryValidatorTests.swift b/TableProTests/Core/Database/SQLBoundaryValidatorTests.swift new file mode 100644 index 000000000..25a541606 --- /dev/null +++ b/TableProTests/Core/Database/SQLBoundaryValidatorTests.swift @@ -0,0 +1,32 @@ +// +// SQLBoundaryValidatorTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("SQLBoundaryValidator") +struct SQLBoundaryValidatorTests { + @Test("Plain filter conditions are allowed") + func allowsPlainConditions() { + #expect(SQLBoundaryValidator.isRawFilterConditionSafe("age > 18")) + #expect(SQLBoundaryValidator.isRawFilterConditionSafe("status IN ('active','pending')")) + #expect(SQLBoundaryValidator.isRawFilterConditionSafe("created_at BETWEEN '2020-01-01' AND '2021-01-01'")) + } + + @Test("Stacked destructive statements are rejected") + func rejectsStackedStatements() { + #expect(!SQLBoundaryValidator.isRawFilterConditionSafe("1=1; DROP TABLE users")) + #expect(!SQLBoundaryValidator.isRawFilterConditionSafe("x = 1 ; delete from t")) + #expect(!SQLBoundaryValidator.isRawFilterConditionSafe("a = 1;TRUNCATE t")) + #expect(!SQLBoundaryValidator.isRawFilterConditionSafe("id = 1; UPDATE t SET x = 2")) + } + + @Test("Comment injection is rejected") + func rejectsCommentInjection() { + #expect(!SQLBoundaryValidator.isRawFilterConditionSafe("id = 1 -- ignored")) + #expect(!SQLBoundaryValidator.isRawFilterConditionSafe("id = 1 /* block */")) + } +} diff --git a/TableProTests/Core/Storage/AppSettingsStorageMigrationTests.swift b/TableProTests/Core/Storage/AppSettingsStorageMigrationTests.swift index 35f71615b..e00b5f340 100644 --- a/TableProTests/Core/Storage/AppSettingsStorageMigrationTests.swift +++ b/TableProTests/Core/Storage/AppSettingsStorageMigrationTests.swift @@ -64,4 +64,40 @@ struct AppSettingsStorageMigrationTests { storage.migrateStartupBehaviorToReopenLastIfNeeded() #expect(storage.loadGeneral().startupBehavior == .showWelcome) } + + private let legacyJsonHeightKey = "rightSidebar.jsonFieldHeight" + + @Test("A saved legacy JSON field height moves to the namespaced key") + func migratesLegacyJsonFieldHeight() { + let (storage, defaults, suite) = makeStorage() + defer { defaults.removePersistentDomain(forName: suite) } + + defaults.set(320.0, forKey: legacyJsonHeightKey) + storage.migrateJsonFieldHeightKeyIfNeeded() + + #expect(defaults.double(forKey: PreferenceKeys.rowInspectorJsonFieldHeight.name) == 320.0) + #expect(defaults.object(forKey: legacyJsonHeightKey) == nil) + } + + @Test("A fresh install writes no JSON field height key") + func skipsJsonFieldHeightOnFreshInstall() { + let (storage, defaults, suite) = makeStorage() + defer { defaults.removePersistentDomain(forName: suite) } + + storage.migrateJsonFieldHeightKeyIfNeeded() + + #expect(defaults.object(forKey: PreferenceKeys.rowInspectorJsonFieldHeight.name) == nil) + } + + @Test("An existing namespaced JSON field height is not overwritten") + func leavesNamespacedJsonFieldHeight() { + let (storage, defaults, suite) = makeStorage() + defer { defaults.removePersistentDomain(forName: suite) } + + defaults.set(200.0, forKey: PreferenceKeys.rowInspectorJsonFieldHeight.name) + defaults.set(320.0, forKey: legacyJsonHeightKey) + storage.migrateJsonFieldHeightKeyIfNeeded() + + #expect(defaults.double(forKey: PreferenceKeys.rowInspectorJsonFieldHeight.name) == 200.0) + } } diff --git a/TableProTests/Core/Storage/AppSettingsStorageResetTests.swift b/TableProTests/Core/Storage/AppSettingsStorageResetTests.swift new file mode 100644 index 000000000..3a724ee27 --- /dev/null +++ b/TableProTests/Core/Storage/AppSettingsStorageResetTests.swift @@ -0,0 +1,26 @@ +// +// AppSettingsStorageResetTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("AppSettingsStorage reset") +struct AppSettingsStorageResetTests { + @Test("Reset clears the selected settings pane and default sidebar layout") + func resetClearsUIOrphans() throws { + let defaults = try #require(UserDefaults(suiteName: "settings-reset-\(UUID().uuidString)")) + let storage = AppSettingsStorage(userDefaults: defaults) + defaults.set("account", forKey: PreferenceKeys.selectedSettingsPane.name) + defaults.set("tree", forKey: SidebarPersistenceKey.defaultLayout) + defaults.set(320.0, forKey: PreferenceKeys.rowInspectorJsonFieldHeight.name) + + storage.resetToDefaults() + + #expect(defaults.string(forKey: PreferenceKeys.selectedSettingsPane.name) == nil) + #expect(defaults.string(forKey: SidebarPersistenceKey.defaultLayout) == nil) + #expect(defaults.object(forKey: PreferenceKeys.rowInspectorJsonFieldHeight.name) == nil) + } +} diff --git a/TableProTests/Core/Storage/CodableListPreferenceStoreTests.swift b/TableProTests/Core/Storage/CodableListPreferenceStoreTests.swift new file mode 100644 index 000000000..2aa8e2d10 --- /dev/null +++ b/TableProTests/Core/Storage/CodableListPreferenceStoreTests.swift @@ -0,0 +1,55 @@ +// +// CodableListPreferenceStoreTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("CodableListPreferenceStore") +struct CodableListPreferenceStoreTests { + private struct Item: Codable, Identifiable, Equatable { + let id: UUID + var label: String + } + + private func makeStore() throws -> CodableListPreferenceStore { + let defaults = try #require(UserDefaults(suiteName: "codablelist-\(UUID().uuidString)")) + return CodableListPreferenceStore(key: DefaultsKey<[Item]>("com.TablePro.test.items"), store: defaults) + } + + @Test("Loading an unset key returns an empty list") + func loadReturnsEmptyWhenUnset() throws { + let store = try makeStore() + #expect(store.load().isEmpty) + } + + @Test("Adding appends and persists") + func addAppendsAndPersists() throws { + let store = try makeStore() + let item = Item(id: UUID(), label: "a") + store.add(item) + #expect(store.load() == [item]) + } + + @Test("Removing by id drops the matching element") + func removeByIdDropsElement() throws { + let store = try makeStore() + let first = Item(id: UUID(), label: "a") + let second = Item(id: UUID(), label: "b") + store.save([first, second]) + store.remove(id: first.id) + #expect(store.load() == [second]) + } + + @Test("Updating replaces the element with the same id") + func updateReplacesMatchingElement() throws { + let store = try makeStore() + var item = Item(id: UUID(), label: "a") + store.add(item) + item.label = "a2" + store.update(item) + #expect(store.load() == [item]) + } +} diff --git a/TableProTests/Core/Storage/ColumnLayoutSyncTests.swift b/TableProTests/Core/Storage/ColumnLayoutSyncTests.swift new file mode 100644 index 000000000..d08c335aa --- /dev/null +++ b/TableProTests/Core/Storage/ColumnLayoutSyncTests.swift @@ -0,0 +1,59 @@ +// +// ColumnLayoutSyncTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Column layout sync") +@MainActor +struct ColumnLayoutSyncTests { + private func makePersister() throws -> (FileColumnLayoutPersister, SyncChangeTracker) { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cl-sync-\(UUID().uuidString)", isDirectory: true) + let meta = try #require(UserDefaults(suiteName: "cl-sync-meta-\(UUID().uuidString)")) + let tracker = SyncChangeTracker(metadataStorage: SyncMetadataStorage(userDefaults: meta)) + return (FileColumnLayoutPersister(storageDirectory: directory, syncTracker: tracker), tracker) + } + + private func key() -> ColumnLayoutTableKey { + ColumnLayoutTableKey(connectionId: UUID(), databaseName: "shop", schemaName: "public", tableName: "orders") + } + + @Test("Saving a layout marks its per-table category dirty") + func saveMarksDirty() throws { + let (persister, tracker) = try makePersister() + let tableKey = key() + var state = ColumnLayoutState() + state.columnWidths = ["id": 80] + persister.save(state, for: tableKey) + + #expect(tracker.dirtyRecords(for: .settings) + .contains(FileColumnLayoutPersister.syncCategory(for: tableKey.storageKey))) + } + + @Test("rawData and applyRemote round-trip a layout to a fresh device") + func rawDataApplyRemoteRoundTrip() throws { + let (source, _) = try makePersister() + let tableKey = key() + var state = ColumnLayoutState() + state.columnWidths = ["id": 80, "name": 200] + state.columnOrder = ["id", "name"] + source.save(state, for: tableKey) + + let data = try #require(source.rawData(forStorageKey: tableKey.storageKey)) + + let (target, _) = try makePersister() + target.applyRemote(storageKey: tableKey.storageKey, data: data) + + #expect(target.load(for: tableKey)?.columnWidths == ["id": 80, "name": 200]) + #expect(target.load(for: tableKey)?.columnOrder == ["id", "name"]) + } + + @Test("The sync category carries the columnLayout prefix") + func categoryPrefix() { + #expect(FileColumnLayoutPersister.syncCategory(for: "abc").hasPrefix(FileColumnLayoutPersister.syncCategoryPrefix)) + } +} diff --git a/TableProTests/Core/Storage/ColumnVisibilityPersistenceTests.swift b/TableProTests/Core/Storage/ColumnVisibilityPersistenceTests.swift deleted file mode 100644 index c366b6c3e..000000000 --- a/TableProTests/Core/Storage/ColumnVisibilityPersistenceTests.swift +++ /dev/null @@ -1,126 +0,0 @@ -// -// ColumnVisibilityPersistenceTests.swift -// TableProTests -// - -import Foundation -import TableProPluginKit -@testable import TablePro -import Testing - -@Suite("ColumnVisibilityPersistence") -@MainActor -struct ColumnVisibilityPersistenceTests { - private func makeDefaults() -> UserDefaults { - let suiteName = "ColumnVisibilityPersistenceTests-\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - fatalError("Failed to create UserDefaults suite for tests") - } - return defaults - } - - private func makeKey( - table: String, - connectionId: UUID = UUID(), - database: String = "app", - schema: String? = "public" - ) -> ColumnLayoutTableKey { - ColumnLayoutTableKey( - connectionId: connectionId, - databaseName: database, - schemaName: schema, - tableName: table - ) - } - - @Test("loadHiddenColumns returns an empty set when no value is stored") - func loadReturnsEmptyByDefault() { - let defaults = makeDefaults() - let result = ColumnVisibilityPersistence.loadHiddenColumns(for: makeKey(table: "users"), defaults: defaults) - #expect(result.isEmpty) - } - - @Test("saveHiddenColumns then loadHiddenColumns round-trips the set") - func roundTripsAcrossSaveAndLoad() { - let defaults = makeDefaults() - let key = makeKey(table: "users") - ColumnVisibilityPersistence.saveHiddenColumns(["email", "phone"], for: key, defaults: defaults) - - let result = ColumnVisibilityPersistence.loadHiddenColumns(for: key, defaults: defaults) - #expect(result == ["email", "phone"]) - } - - @Test("Different tables under the same connection store independent sets") - func tablesAreScopedSeparately() { - let defaults = makeDefaults() - let connectionId = UUID() - let users = makeKey(table: "users", connectionId: connectionId) - let orders = makeKey(table: "orders", connectionId: connectionId) - ColumnVisibilityPersistence.saveHiddenColumns(["a"], for: users, defaults: defaults) - ColumnVisibilityPersistence.saveHiddenColumns(["b"], for: orders, defaults: defaults) - - #expect(ColumnVisibilityPersistence.loadHiddenColumns(for: users, defaults: defaults) == ["a"]) - #expect(ColumnVisibilityPersistence.loadHiddenColumns(for: orders, defaults: defaults) == ["b"]) - } - - @Test("Different connections store independent sets for the same table name") - func connectionsAreScopedSeparately() { - let defaults = makeDefaults() - let a = makeKey(table: "users", connectionId: UUID()) - let b = makeKey(table: "users", connectionId: UUID()) - ColumnVisibilityPersistence.saveHiddenColumns(["x"], for: a, defaults: defaults) - ColumnVisibilityPersistence.saveHiddenColumns(["y"], for: b, defaults: defaults) - - #expect(ColumnVisibilityPersistence.loadHiddenColumns(for: a, defaults: defaults) == ["x"]) - #expect(ColumnVisibilityPersistence.loadHiddenColumns(for: b, defaults: defaults) == ["y"]) - } - - @Test("Same table name in different databases does not collide") - func databasesAreScopedSeparately() { - let defaults = makeDefaults() - let connectionId = UUID() - let sales = makeKey(table: "users", connectionId: connectionId, database: "sales") - let hr = makeKey(table: "users", connectionId: connectionId, database: "hr") - ColumnVisibilityPersistence.saveHiddenColumns(["salary"], for: sales, defaults: defaults) - ColumnVisibilityPersistence.saveHiddenColumns(["ssn"], for: hr, defaults: defaults) - - #expect(ColumnVisibilityPersistence.loadHiddenColumns(for: sales, defaults: defaults) == ["salary"]) - #expect(ColumnVisibilityPersistence.loadHiddenColumns(for: hr, defaults: defaults) == ["ssn"]) - } - - @Test("Same table name in different schemas does not collide") - func schemasAreScopedSeparately() { - let defaults = makeDefaults() - let connectionId = UUID() - let publicUsers = makeKey(table: "users", connectionId: connectionId, schema: "public") - let authUsers = makeKey(table: "users", connectionId: connectionId, schema: "auth") - ColumnVisibilityPersistence.saveHiddenColumns(["a"], for: publicUsers, defaults: defaults) - ColumnVisibilityPersistence.saveHiddenColumns(["b"], for: authUsers, defaults: defaults) - - #expect(ColumnVisibilityPersistence.loadHiddenColumns(for: publicUsers, defaults: defaults) == ["a"]) - #expect(ColumnVisibilityPersistence.loadHiddenColumns(for: authUsers, defaults: defaults) == ["b"]) - } - - @Test("Saving an empty set clears stored state") - func savingEmptySetClearsState() { - let defaults = makeDefaults() - let key = makeKey(table: "users") - ColumnVisibilityPersistence.saveHiddenColumns(["leftover"], for: key, defaults: defaults) - ColumnVisibilityPersistence.saveHiddenColumns([], for: key, defaults: defaults) - - #expect(ColumnVisibilityPersistence.loadHiddenColumns(for: key, defaults: defaults).isEmpty) - } - - @Test("Storage key carries the hidden-columns prefix and separates scopes") - func keyFormat() { - let connectionId = UUID() - let publicKey = ColumnVisibilityPersistence.key( - for: makeKey(table: "users", connectionId: connectionId, database: "app", schema: "public") - ) - let authKey = ColumnVisibilityPersistence.key( - for: makeKey(table: "users", connectionId: connectionId, database: "app", schema: "auth") - ) - #expect(publicKey.hasPrefix("com.TablePro.columns.hiddenColumns.")) - #expect(publicKey != authKey) - } -} diff --git a/TableProTests/Core/Storage/FilterSettingsStorageTests.swift b/TableProTests/Core/Storage/FilterSettingsStorageTests.swift index 8f6412b44..54c7584c0 100644 --- a/TableProTests/Core/Storage/FilterSettingsStorageTests.swift +++ b/TableProTests/Core/Storage/FilterSettingsStorageTests.swift @@ -302,4 +302,74 @@ struct FilterSettingsStorageTests { .isActive ) } + + @Test("The filter logic mode round-trips alongside the filters") + func logicModeRoundTrips() { + let (storage, directory) = makeStorage() + defer { try? FileManager.default.removeItem(at: directory) } + let connectionId = UUID() + let filters = [ + TestFixtures.makeTableFilter(column: "a"), + TestFixtures.makeTableFilter(column: "b"), + ] + + storage.saveLastFilters( + filters, logicMode: .or, for: "users", connectionId: connectionId, databaseName: "db", schemaName: nil + ) + + let state = storage.loadLastFilterState( + for: "users", connectionId: connectionId, databaseName: "db", schemaName: nil + ) + #expect(state.filters == filters) + #expect(state.logicMode == .or) + } + + @Test("The logic mode survives a fresh storage instance") + func logicModePersistsAcrossInstances() throws { + let defaults = try #require(UserDefaults(suiteName: "FilterSettingsStorageTests-\(UUID().uuidString)")) + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("FilterSettingsStorageTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let connectionId = UUID() + + let writer = FilterSettingsStorage(filterStateDirectory: directory, defaults: defaults) + writer.saveLastFilters( + [TestFixtures.makeTableFilter(column: "a")], + logicMode: .or, for: "users", connectionId: connectionId, databaseName: "db", schemaName: nil + ) + writer.waitForPendingDiskWrites() + + let reader = FilterSettingsStorage(filterStateDirectory: directory, defaults: defaults) + #expect( + reader.loadLastFilterState( + for: "users", connectionId: connectionId, databaseName: "db", schemaName: nil + ).logicMode == .or + ) + } + + @Test("A legacy bare-array filter file loads with the default AND logic mode") + func legacyArrayFileLoadsWithAndMode() throws { + let defaults = try #require(UserDefaults(suiteName: "FilterSettingsStorageTests-\(UUID().uuidString)")) + defaults.set(true, forKey: "com.TablePro.filterStateMigrationComplete") + defaults.set(true, forKey: "com.TablePro.filterStateCompositeKeyMigrationComplete") + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("FilterSettingsStorageTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let connectionId = UUID() + let filters = [TestFixtures.makeTableFilter(column: "email", value: "a@b.com")] + + let storage = FilterSettingsStorage(filterStateDirectory: directory, defaults: defaults) + let compositeKey = CompositeStorageKey.make( + connectionId: connectionId, databaseName: "db", schemaName: nil, tableName: "users" + ) + let fileURL = directory.appendingPathComponent("\(compositeKey).json") + try JSONEncoder().encode(filters).write(to: fileURL) + + let state = storage.loadLastFilterState( + for: "users", connectionId: connectionId, databaseName: "db", schemaName: nil + ) + #expect(state.filters == filters) + #expect(state.logicMode == .and) + } } diff --git a/TableProTests/Core/Storage/KeychainStringResultValueTests.swift b/TableProTests/Core/Storage/KeychainStringResultValueTests.swift new file mode 100644 index 000000000..f4d6c359a --- /dev/null +++ b/TableProTests/Core/Storage/KeychainStringResultValueTests.swift @@ -0,0 +1,28 @@ +// +// KeychainStringResultValueTests.swift +// TableProTests +// + +import Foundation +import os +@testable import TablePro +import Testing + +@Suite("KeychainStringResult.value") +struct KeychainStringResultValueTests { + private let logger = Logger(subsystem: "com.TablePro.tests", category: "keychain") + + @Test("Found returns the value") + func foundReturnsValue() { + #expect(KeychainStringResult.found("secret").value(label: "password", logger: logger) == "secret") + } + + @Test("Every non-found outcome returns nil") + func nonFoundReturnsNil() { + #expect(KeychainStringResult.notFound.value(label: "password", logger: logger) == nil) + #expect(KeychainStringResult.locked.value(label: "password", logger: logger) == nil) + #expect(KeychainStringResult.userCancelled.value(label: "password", logger: logger) == nil) + #expect(KeychainStringResult.authFailed.value(label: "password", logger: logger) == nil) + #expect(KeychainStringResult.error(-25_300).value(label: "password", logger: logger) == nil) + } +} diff --git a/TableProTests/Core/Storage/LinkedFolderStoreTests.swift b/TableProTests/Core/Storage/LinkedFolderStoreTests.swift new file mode 100644 index 000000000..bd7e5a03a --- /dev/null +++ b/TableProTests/Core/Storage/LinkedFolderStoreTests.swift @@ -0,0 +1,33 @@ +// +// LinkedFolderStoreTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Linked folder stores") +struct LinkedFolderStoreTests { + @Test("LinkedFolderStorage adds and removes through the shared implementation") + func linkedFolderRoundTrips() throws { + let defaults = try #require(UserDefaults(suiteName: "linkedfolder-\(UUID().uuidString)")) + let storage = LinkedFolderStorage(defaults: defaults) + let folder = LinkedFolder(path: "/tmp/exports") + storage.addFolder(folder) + #expect(storage.loadFolders().map(\.id) == [folder.id]) + storage.removeFolder(folder) + #expect(storage.loadFolders().isEmpty) + } + + @Test("LinkedSQLFolderStorage updates through the shared implementation") + func linkedSQLFolderUpdates() throws { + let defaults = try #require(UserDefaults(suiteName: "linkedsqlfolder-\(UUID().uuidString)")) + let storage = LinkedSQLFolderStorage(defaults: defaults) + var folder = LinkedSQLFolder(path: "/tmp/queries") + storage.addFolder(folder) + folder.isEnabled = false + storage.updateFolder(folder) + #expect(storage.loadFolders() == [folder]) + } +} diff --git a/TableProTests/Core/Storage/PreferenceKeysGuardTests.swift b/TableProTests/Core/Storage/PreferenceKeysGuardTests.swift new file mode 100644 index 000000000..4b2765d1c --- /dev/null +++ b/TableProTests/Core/Storage/PreferenceKeysGuardTests.swift @@ -0,0 +1,82 @@ +// +// PreferenceKeysGuardTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Preference key registry & guard") +struct PreferenceKeysGuardTests { + @Test("Registered keys are unique and namespaced") + func registryIsCleanlyNamespaced() { + let names = PreferenceKeys.registeredKeyNames + #expect(Set(names).count == names.count) + for name in names { + #expect(name.hasPrefix("com.TablePro."), "Key '\(name)' is outside the com.TablePro namespace") + } + } + + @Test("No off-namespace forKey: literals outside the frozen baseline") + func noNewRawForKeyLiterals() throws { + let offenders = try Self.scan(pattern: #"forKey:\s*"([^"\\]+)""#) + .filter { !$0.hasPrefix("com.TablePro") && Self.grandfatheredForKey[$0] == nil } + #expect(offenders.isEmpty, "Route new UserDefaults keys through PreferenceKeys: \(offenders.sorted())") + } + + @Test("No off-namespace @AppStorage literals outside the frozen baseline") + func noNewRawAppStorageLiterals() throws { + let offenders = try Self.scan(pattern: #"@AppStorage\(\s*"([^"\\]+)""#) + .filter { !$0.hasPrefix("com.TablePro") && Self.grandfatheredAppStorage[$0] == nil } + #expect(offenders.isEmpty, "Route new @AppStorage keys through the preferences layer: \(offenders.sorted())") + } + + private static let grandfatheredForKey: [String: String] = [ + "AppleLanguages": "Apple system default written when switching app language", + "blink": "CALayer animation key in VimCursorManager, not a preference", + "preConnectScript": "additionalFields dictionary key in ConnectionFormCoordinator, not a preference", + ] + + private static let grandfatheredAppStorage: [String: String] = [ + "hideExportSuccessDialog": "legacy export flag, migrates to PreferenceKeys in a later phase", + "skipSchemaPreview": "legacy schema-preview flag, migrates to PreferenceKeys in a later phase", + "structureCodeFontSize": "legacy structure font size, migrates to PreferenceKeys in a later phase", + ] + + private static func scan(pattern: String) throws -> Set { + let sourceRoot = try repoRoot().appendingPathComponent("TablePro") + let regex = try NSRegularExpression(pattern: pattern) + guard let enumerator = FileManager.default.enumerator( + at: sourceRoot, + includingPropertiesForKeys: [.isRegularFileKey] + ) else { return [] } + + var matches: Set = [] + for case let url as URL in enumerator where url.pathExtension == "swift" { + let text = try String(contentsOf: url, encoding: .utf8) + let range = NSRange(text.startIndex..., in: text) + for match in regex.matches(in: text, range: range) where match.numberOfRanges > 1 { + if let captured = Range(match.range(at: 1), in: text) { + matches.insert(String(text[captured])) + } + } + } + return matches + } + + private static func repoRoot() throws -> URL { + var directory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + for _ in 0 ..< 12 { + if FileManager.default.fileExists(atPath: directory.appendingPathComponent("TablePro.xcodeproj").path) { + return directory + } + directory = directory.deletingLastPathComponent() + } + throw GuardError.repoRootNotFound + } + + private enum GuardError: Error { + case repoRootNotFound + } +} diff --git a/TableProTests/Core/Storage/RecentTablesStoreMigrationTests.swift b/TableProTests/Core/Storage/RecentTablesStoreMigrationTests.swift new file mode 100644 index 000000000..5d9e0c23a --- /dev/null +++ b/TableProTests/Core/Storage/RecentTablesStoreMigrationTests.swift @@ -0,0 +1,48 @@ +// +// RecentTablesStoreMigrationTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("RecentTablesStore migration") +@MainActor +struct RecentTablesStoreMigrationTests { + @Test("Migrates the legacy RecentTables.v1 key to the namespaced key") + func migratesLegacyKey() throws { + let defaults = try #require(UserDefaults(suiteName: "recent-\(UUID().uuidString)")) + let store = RecentTablesStore(defaults: defaults) + let conn = UUID() + let entry = RecentTableEntry( + database: "shop", + schema: "public", + name: "orders", + isView: false, + openedAt: Date(timeIntervalSince1970: 100) + ) + let legacyKey = "RecentTables.v1.\(conn.uuidString)" + defaults.set(try JSONEncoder().encode([entry]), forKey: legacyKey) + + #expect(store.entries(connectionId: conn) == [entry]) + #expect(defaults.data(forKey: legacyKey) == nil) + #expect(defaults.data(forKey: PreferenceKeys.recentTables(connectionId: conn).name) != nil) + } + + @Test("Records and reads through the namespaced key") + func recordRoundTrip() throws { + let defaults = try #require(UserDefaults(suiteName: "recent-\(UUID().uuidString)")) + let store = RecentTablesStore(defaults: defaults) + let conn = UUID() + store.record( + connectionId: conn, + database: "shop", + schema: "public", + name: "orders", + isView: false, + at: Date(timeIntervalSince1970: 1) + ) + #expect(store.entries(connectionId: conn).map(\.name) == ["orders"]) + } +} diff --git a/TableProTests/Core/Storage/SavedCustomizationsServiceTests.swift b/TableProTests/Core/Storage/SavedCustomizationsServiceTests.swift new file mode 100644 index 000000000..cd01abbc1 --- /dev/null +++ b/TableProTests/Core/Storage/SavedCustomizationsServiceTests.swift @@ -0,0 +1,53 @@ +// +// SavedCustomizationsServiceTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("SavedCustomizationsService") +@MainActor +struct SavedCustomizationsServiceTests { + private func makeStores() throws -> (FileColumnLayoutPersister, FilterSettingsStorage) { + let layoutDir = FileManager.default.temporaryDirectory + .appendingPathComponent("sc-layout-\(UUID().uuidString)", isDirectory: true) + let filterDir = FileManager.default.temporaryDirectory + .appendingPathComponent("sc-filter-\(UUID().uuidString)", isDirectory: true) + let defaults = try #require(UserDefaults(suiteName: "sc-\(UUID().uuidString)")) + return ( + FileColumnLayoutPersister(storageDirectory: layoutDir), + FilterSettingsStorage(filterStateDirectory: filterDir, defaults: defaults) + ) + } + + @Test("Lists a table with a saved layout and filters, then resets it") + func listsAndResets() throws { + let (layout, filter) = try makeStores() + let connectionId = UUID() + + var state = ColumnLayoutState() + state.columnWidths = ["id": 80] + layout.save(state, for: ColumnLayoutTableKey( + connectionId: connectionId, databaseName: "shop", schemaName: "public", tableName: "orders" + )) + filter.saveLastFilters( + [TestFixtures.makeTableFilter(column: "id")], + for: "orders", connectionId: connectionId, databaseName: "shop", schemaName: "public" + ) + filter.waitForPendingDiskWrites() + + let items = SavedCustomizationsService.all(layoutStore: layout, filterStore: filter) + #expect(items.count == 1) + #expect(items.first?.hasLayout == true) + #expect(items.first?.hasFilters == true) + #expect(items.first?.scope.displayName == "shop.public.orders") + + let scope = try #require(items.first?.scope) + SavedCustomizationsService.reset(scope, layoutStore: layout, filterStore: filter) + filter.waitForPendingDiskWrites() + + #expect(SavedCustomizationsService.all(layoutStore: layout, filterStore: filter).isEmpty) + } +} diff --git a/TableProTests/Core/Storage/TableScopeDecodeTests.swift b/TableProTests/Core/Storage/TableScopeDecodeTests.swift new file mode 100644 index 000000000..7c8282937 --- /dev/null +++ b/TableProTests/Core/Storage/TableScopeDecodeTests.swift @@ -0,0 +1,40 @@ +// +// TableScopeDecodeTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("TableScope decode") +struct TableScopeDecodeTests { + @Test("Round-trips a full scope through the storage component") + func roundTrips() { + let scope = TableScope(connectionId: UUID(), database: "shop", schema: "public", table: "orders") + #expect(TableScope(storageComponent: scope.storageComponent) == scope) + } + + @Test("Round-trips with nil database and schema") + func roundTripsWithNils() { + let scope = TableScope(connectionId: UUID(), database: nil, schema: nil, table: "t") + #expect(TableScope(storageComponent: scope.storageComponent) == scope) + } + + @Test("Round-trips identifiers that contain separators and spaces") + func roundTripsSpecialCharacters() { + let scope = TableScope(connectionId: UUID(), database: "my.db", schema: "a b", table: "t.able") + #expect(TableScope(storageComponent: scope.storageComponent) == scope) + } + + @Test("displayName joins the present parts") + func displayName() { + let scope = TableScope(connectionId: UUID(), database: "shop", schema: nil, table: "orders") + #expect(scope.displayName == "shop.orders") + } + + @Test("Rejects a malformed storage component") + func rejectsMalformed() { + #expect(TableScope(storageComponent: "not-a-key") == nil) + } +} diff --git a/TableProTests/Core/Storage/TableScopeTests.swift b/TableProTests/Core/Storage/TableScopeTests.swift new file mode 100644 index 000000000..8942f53e0 --- /dev/null +++ b/TableProTests/Core/Storage/TableScopeTests.swift @@ -0,0 +1,48 @@ +// +// TableScopeTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("TableScope") +struct TableScopeTests { + @Test("storageComponent distinguishes schemas") + func schemaDistinguishesKey() { + let conn = UUID() + let publicScope = TableScope(connectionId: conn, database: "shop", schema: "public", table: "orders") + let archiveScope = TableScope(connectionId: conn, database: "shop", schema: "archive", table: "orders") + #expect(publicScope.storageComponent != archiveScope.storageComponent) + } + + @Test("storageComponent survives separator characters in identifiers") + func encodesSeparators() { + let conn = UUID() + let dotted = TableScope(connectionId: conn, database: "a.b", schema: nil, table: "c") + let split = TableScope(connectionId: conn, database: "a", schema: "b", table: "c") + #expect(dotted.storageComponent != split.storageComponent) + } + + @Test("CompositeStorageKey matches TableScope.storageComponent") + func compositeKeyEquivalence() { + let conn = UUID() + let scope = TableScope(connectionId: conn, database: "db", schema: "public", table: "t") + let composite = CompositeStorageKey.make( + connectionId: conn, + databaseName: "db", + schemaName: "public", + tableName: "t" + ) + #expect(composite == scope.storageComponent) + } + + @Test("Scoped preference keys are namespaced") + func scopedKeysNamespaced() { + let conn = UUID() + let scope = TableScope(connectionId: conn, database: "db", schema: "public", table: "t") + #expect(PreferenceKeys.columnDisplayFormats(scope).name.hasPrefix("com.TablePro.")) + #expect(PreferenceKeys.recentTables(connectionId: conn).name.hasPrefix("com.TablePro.")) + } +} diff --git a/TableProTests/Core/Storage/ValueDisplayFormatStorageTests.swift b/TableProTests/Core/Storage/ValueDisplayFormatStorageTests.swift new file mode 100644 index 000000000..949a0ad73 --- /dev/null +++ b/TableProTests/Core/Storage/ValueDisplayFormatStorageTests.swift @@ -0,0 +1,53 @@ +// +// ValueDisplayFormatStorageTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("ValueDisplayFormatStorage") +@MainActor +struct ValueDisplayFormatStorageTests { + private func makeStorage() throws -> (ValueDisplayFormatStorage, UserDefaults) { + let defaults = try #require(UserDefaults(suiteName: "vdf-\(UUID().uuidString)")) + return (ValueDisplayFormatStorage(defaults: defaults), defaults) + } + + private func scope(_ schema: String, connectionId: UUID, table: String = "orders") -> TableScope { + TableScope(connectionId: connectionId, database: "shop", schema: schema, table: table) + } + + @Test("Round-trips and clears per scope") + func roundTrip() throws { + let (storage, _) = try makeStorage() + let target = scope("public", connectionId: UUID()) + storage.save(["id": .uuid], for: target) + #expect(storage.load(for: target) == ["id": .uuid]) + storage.clear(for: target) + #expect(storage.load(for: target) == nil) + } + + @Test("Same table name in different schemas does not collide") + func schemasDoNotCollide() throws { + let (storage, _) = try makeStorage() + let conn = UUID() + storage.save(["id": .uuid], for: scope("public", connectionId: conn)) + #expect(storage.load(for: scope("public", connectionId: conn)) == ["id": .uuid]) + #expect(storage.load(for: scope("archive", connectionId: conn)) == nil) + } + + @Test("Migrates legacy schema-blind formats on first load") + func migratesLegacy() throws { + let (storage, defaults) = try makeStorage() + let conn = UUID() + let target = scope("public", connectionId: conn) + let legacyKey = "com.TablePro.columns.displayFormat.\(conn.uuidString).orders" + defaults.set(try JSONEncoder().encode(["id": ValueDisplayFormat.uuid]), forKey: legacyKey) + + #expect(storage.load(for: target) == ["id": .uuid]) + #expect(defaults.data(forKey: legacyKey) == nil) + #expect(defaults.data(forKey: PreferenceKeys.columnDisplayFormats(target).name) != nil) + } +} diff --git a/TableProTests/Core/Sync/SyncScopeTests.swift b/TableProTests/Core/Sync/SyncScopeTests.swift new file mode 100644 index 000000000..91f2dd905 --- /dev/null +++ b/TableProTests/Core/Sync/SyncScopeTests.swift @@ -0,0 +1,26 @@ +// +// SyncScopeTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Sync scope") +struct SyncScopeTests { + @Test("Every current record type is declared synced") + func allCurrentTypesSync() { + for type in SyncRecordType.allCases { + #expect(type.syncScope == .synced) + } + } + + @Test("markDirty records a synced record type") + func markDirtyRecordsSyncedType() throws { + let defaults = try #require(UserDefaults(suiteName: "syncscope-\(UUID().uuidString)")) + let tracker = SyncChangeTracker(metadataStorage: SyncMetadataStorage(userDefaults: defaults)) + tracker.markDirty(.settings, id: "general") + #expect(tracker.dirtyRecords(for: .settings).contains("general")) + } +} diff --git a/TableProTests/Storage/FileColumnLayoutPersisterTests.swift b/TableProTests/Storage/FileColumnLayoutPersisterTests.swift index 04303e065..521351073 100644 --- a/TableProTests/Storage/FileColumnLayoutPersisterTests.swift +++ b/TableProTests/Storage/FileColumnLayoutPersisterTests.swift @@ -313,4 +313,120 @@ struct FileColumnLayoutPersisterTests { let persister = FileColumnLayoutPersister(storageDirectory: directory) #expect(persister.load(for: key("anything", connectionId)) == nil) } + + @Test("Hidden columns round-trip and default to empty") + func hiddenColumnsRoundTrip() { + let (persister, dir) = makeIsolatedPersister() + defer { cleanup(dir) } + + let tableKey = key("users", UUID()) + #expect(persister.loadHiddenColumns(for: tableKey).isEmpty) + persister.saveHiddenColumns(["a", "b"], for: tableKey) + #expect(persister.loadHiddenColumns(for: tableKey) == ["a", "b"]) + } + + @Test("Saving geometry preserves previously hidden columns (#1815)") + func saveGeometryPreservesHiddenColumns() { + let (persister, dir) = makeIsolatedPersister() + defer { cleanup(dir) } + + let tableKey = key("users", UUID()) + persister.saveHiddenColumns(["email"], for: tableKey) + + var geometry = ColumnLayoutState() + geometry.columnWidths = ["id": 80, "name": 200] + geometry.columnOrder = ["id", "name"] + persister.save(geometry, for: tableKey) + + #expect(persister.loadHiddenColumns(for: tableKey) == ["email"]) + #expect(persister.load(for: tableKey)?.columnWidths == ["id": 80, "name": 200]) + } + + @Test("Saving hidden columns preserves stored geometry") + func saveHiddenPreservesGeometry() { + let (persister, dir) = makeIsolatedPersister() + defer { cleanup(dir) } + + let tableKey = key("users", UUID()) + var geometry = ColumnLayoutState() + geometry.columnWidths = ["id": 80] + geometry.columnOrder = ["id"] + persister.save(geometry, for: tableKey) + persister.saveHiddenColumns(["email"], for: tableKey) + + #expect(persister.load(for: tableKey)?.columnWidths == ["id": 80]) + #expect(persister.load(for: tableKey)?.columnOrder == ["id"]) + #expect(persister.loadHiddenColumns(for: tableKey) == ["email"]) + } + + @Test("A hidden-only entry loads as nil geometry") + func hiddenOnlyEntryLoadsAsNilGeometry() { + let (persister, dir) = makeIsolatedPersister() + defer { cleanup(dir) } + + let tableKey = key("users", UUID()) + persister.saveHiddenColumns(["email"], for: tableKey) + #expect(persister.load(for: tableKey) == nil) + #expect(persister.loadHiddenColumns(for: tableKey) == ["email"]) + } + + @Test("Saving an empty hidden set clears hidden but keeps geometry") + func savingEmptyHiddenClears() { + let (persister, dir) = makeIsolatedPersister() + defer { cleanup(dir) } + + let tableKey = key("users", UUID()) + var geometry = ColumnLayoutState() + geometry.columnWidths = ["id": 80] + persister.save(geometry, for: tableKey) + persister.saveHiddenColumns(["email"], for: tableKey) + persister.saveHiddenColumns([], for: tableKey) + + #expect(persister.loadHiddenColumns(for: tableKey).isEmpty) + #expect(persister.load(for: tableKey)?.columnWidths == ["id": 80]) + } + + @Test("Hidden columns are scoped by schema") + func hiddenColumnsScopedBySchema() { + let (persister, dir) = makeIsolatedPersister() + defer { cleanup(dir) } + + let connectionId = UUID() + let publicKey = key("users", connectionId, database: "app", schema: "public") + let authKey = key("users", connectionId, database: "app", schema: "auth") + persister.saveHiddenColumns(["a"], for: publicKey) + persister.saveHiddenColumns(["b"], for: authKey) + + #expect(persister.loadHiddenColumns(for: publicKey) == ["a"]) + #expect(persister.loadHiddenColumns(for: authKey) == ["b"]) + } + + @Test("Clear removes hidden columns too") + func clearRemovesHidden() { + let (persister, dir) = makeIsolatedPersister() + defer { cleanup(dir) } + + let tableKey = key("users", UUID()) + persister.saveHiddenColumns(["email"], for: tableKey) + persister.clear(for: tableKey) + #expect(persister.loadHiddenColumns(for: tableKey).isEmpty) + } + + @Test("Legacy hidden-columns UserDefaults key migrates into the store on first load") + func migratesLegacyVisibilityKey() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("TableProTests-\(UUID().uuidString)", isDirectory: true) + defer { cleanup(directory) } + let defaults = try #require(UserDefaults(suiteName: "colvis-\(UUID().uuidString)")) + let tableKey = key("users", UUID()) + let legacyKey = "com.TablePro.columns.hiddenColumns." + tableKey.storageKey + defaults.set(["email", "phone"], forKey: legacyKey) + + let persister = FileColumnLayoutPersister(storageDirectory: directory, defaults: defaults) + #expect(persister.loadHiddenColumns(for: tableKey) == ["email", "phone"]) + #expect(defaults.stringArray(forKey: legacyKey) == nil) + + let fresh = FileColumnLayoutPersister(storageDirectory: directory, defaults: defaults) + #expect(fresh.loadHiddenColumns(for: tableKey) == ["email", "phone"]) + } } diff --git a/TableProTests/ViewModels/WindowSidebarStateTests.swift b/TableProTests/ViewModels/WindowSidebarStateTests.swift index e783226c6..c86f5d9ea 100644 --- a/TableProTests/ViewModels/WindowSidebarStateTests.swift +++ b/TableProTests/ViewModels/WindowSidebarStateTests.swift @@ -6,6 +6,7 @@ // selectedTables was shared across windows of the same connection, causing // Cmd+T to jump focus back to a sibling window. Sidebar filter text is // connection-scoped and lives in SharedSidebarState; see SharedSidebarStateTests. +// Also pins per-connection persistence of database-tree expansion. // import Foundation @@ -26,4 +27,57 @@ struct WindowSidebarStateTests { #expect(windowA.selectedTables == [users]) #expect(windowB.selectedTables.isEmpty) } + + private func makeDefaults() throws -> UserDefaults { + try #require(UserDefaults(suiteName: "sidebar-tree-\(UUID().uuidString)")) + } + + @Test("Tree expansion persists and restores across instances for a connection") + func persistsAndRestores() throws { + let defaults = try makeDefaults() + let connectionId = UUID() + + let state = WindowSidebarState(connectionId: connectionId, defaults: defaults) + state.expandedTreeDatabases.insert("shop") + state.expandedTreeSchemas.insert("public") + state.expandedTreeDatabaseSchemas.insert(DatabaseSchemaKey(database: "shop", schema: "public")) + + let restored = WindowSidebarState(connectionId: connectionId, defaults: defaults) + #expect(restored.expandedTreeDatabases == ["shop"]) + #expect(restored.expandedTreeSchemas == ["public"]) + #expect(restored.expandedTreeDatabaseSchemas.contains(DatabaseSchemaKey(database: "shop", schema: "public"))) + } + + @Test("Different connections keep independent expansion") + func connectionsAreIsolated() throws { + let defaults = try makeDefaults() + let first = UUID() + let second = UUID() + + WindowSidebarState(connectionId: first, defaults: defaults).expandedTreeDatabases.insert("a") + + let secondState = WindowSidebarState(connectionId: second, defaults: defaults) + #expect(secondState.expandedTreeDatabases.isEmpty) + } + + @Test("Collapsing everything removes stored expansion") + func clearingRemovesStorage() throws { + let defaults = try makeDefaults() + let connectionId = UUID() + + let state = WindowSidebarState(connectionId: connectionId, defaults: defaults) + state.expandedTreeDatabases.insert("shop") + state.expandedTreeDatabases.removeAll() + + let restored = WindowSidebarState(connectionId: connectionId, defaults: defaults) + #expect(restored.expandedTreeDatabases.isEmpty) + } + + @Test("A window without a connection does not persist") + func nilConnectionDoesNotPersist() throws { + let defaults = try makeDefaults() + let state = WindowSidebarState(connectionId: nil, defaults: defaults) + state.expandedTreeDatabases.insert("x") + #expect(state.expandedTreeDatabases == ["x"]) + } } diff --git a/TableProTests/Views/Main/CoordinatorColumnVisibilityTests.swift b/TableProTests/Views/Main/CoordinatorColumnVisibilityTests.swift index ca37c029c..06cb42a0a 100644 --- a/TableProTests/Views/Main/CoordinatorColumnVisibilityTests.swift +++ b/TableProTests/Views/Main/CoordinatorColumnVisibilityTests.swift @@ -68,11 +68,11 @@ struct CoordinatorColumnVisibilityTests { schemaName: tab.tableContext.schemaName, tableName: "users" ) - defer { UserDefaults.standard.removeObject(forKey: ColumnVisibilityPersistence.key(for: key)) } + defer { FileColumnLayoutPersister.shared.clear(for: key) } coordinator.hideColumn("email") - #expect(ColumnVisibilityPersistence.loadHiddenColumns(for: key) == ["email"]) + #expect(FileColumnLayoutPersister.shared.loadHiddenColumns(for: key) == ["email"]) } @Test("Applying column geometry after hiding columns keeps the hidden set and syncs the session") @@ -202,11 +202,11 @@ struct CoordinatorColumnVisibilityTests { ) defer { - UserDefaults.standard.removeObject(forKey: ColumnVisibilityPersistence.key(for: key)) + FileColumnLayoutPersister.shared.clear(for: key) coordinator.teardown() } - ColumnVisibilityPersistence.saveHiddenColumns(["email"], for: key) + FileColumnLayoutPersister.shared.saveHiddenColumns(["email"], for: key) coordinator.schemaColumns.store( (columns: ["id", "name", "email"], primaryKeys: ["id"]), for: coordinator.schemaColumnsKey("users", schema: nil) diff --git a/TableProTests/Views/Main/FilterRestoreTests.swift b/TableProTests/Views/Main/FilterRestoreTests.swift index 48a7b1780..a4c7716a8 100644 --- a/TableProTests/Views/Main/FilterRestoreTests.swift +++ b/TableProTests/Views/Main/FilterRestoreTests.swift @@ -25,6 +25,23 @@ struct FilterRestoreTests { #expect(result.isVisible) } + @Test("Restore Last restores the saved logic mode instead of defaulting to AND") + func restoreLastRestoresLogicMode() { + let saved = [ + TestFixtures.makeTableFilter(column: "a"), + TestFixtures.makeTableFilter(column: "b"), + ] + + let result = FilterCoordinator.resolvedRestoredState( + panelState: .restoreLast, + saved: saved, + savedLogicMode: .or, + current: TabFilterState() + ) + + #expect(result.filterLogicMode == .or) + } + @Test("Restore keeps disabled filters in the panel but out of the applied set") func restoreKeepsDisabledFilterInactive() { let active = TestFixtures.makeTableFilter(column: "email", value: "a@b.com") diff --git a/docs/customization/data-settings.mdx b/docs/customization/data-settings.mdx new file mode 100644 index 000000000..9e82d71c0 --- /dev/null +++ b/docs/customization/data-settings.mdx @@ -0,0 +1,62 @@ +--- +title: Data Settings +description: Data grid, pagination, query result cap, JSON viewer, query history, and saved customizations. +--- + +# Data Settings + +Open **Settings** > **Data** (`Cmd+,`). This tab holds the data grid, pagination, query result cap, JSON viewer, query history, and saved per-table customizations. + +Data grid fonts are per-theme. Edit them on the theme's Fonts tab under [Appearance](/customization/appearance). + +## Data Grid + +| Setting | Default | Notes | +|---------|---------|-------| +| Row height | Normal | Compact, Normal, Comfortable, Spacious | +| Date format | ISO 8601 | ISO 8601, ISO Date, US Long, US Short, EU Long, EU Short | +| NULL display | `NULL` | Text shown for NULL cells, up to 20 characters | +| Show alternate row backgrounds | On | | +| Show row numbers | On | | +| Auto-show inspector on row select | Off | Opens the row details inspector when you select a row | +| Smart value detection | On | Renders UUID, Unix timestamp, JSON, and PHP serialized columns in readable form. Override per column with Display As, see [Data Grid](/features/data-grid) | +| Default row sort | No sorting (engine order) | Or Primary key, First column. Applied when a table first opens; clicking a column header overrides it | + +## Pagination + +| Setting | Default | Options | +|---------|---------|---------| +| Default page size | 1,000 rows | 100, 500, 1,000, 5,000, 10,000 | +| Count rows if estimate less than | 100,000 | 1,000; 10,000; 100,000; 1,000,000; Always count | + +Tables with more estimated rows than the threshold show an approximate count instead of running a slow `COUNT(*)`. + +## Query Result Row Cap + +| Setting | Default | Options | +|---------|---------|---------| +| Truncate query results | On | | +| Row cap | 10,000 | 100; 1,000; 5,000; 10,000; 50,000; 100,000; 500,000 | + +When on, `SELECT` queries without their own `LIMIT` run with the cap applied to the rows TablePro reads back. The SQL sent to the server is always the query you wrote. Capped results show a **Fetch All** button, and **Execute Without Limit** skips the cap for one run. + +## JSON Viewer + +| Setting | Default | Options | +|---------|---------|---------| +| Default view | Text | Text, Tree | + +The mode the JSON viewer opens in. Switching modes inside the viewer writes the choice back to this setting. See [JSON Viewer](/features/json-viewer). + +## Query History + +| Setting | Default | Description | +|---------|---------|-------------| +| **Maximum entries** | 10,000 | 100 to 10,000, or Unlimited | +| **Keep entries for** | 90 days | 7 days to 1 year, or Forever | +| **Auto cleanup on startup** | On | Remove old entries automatically | +| **Clear History...** | - | Wipe all history after confirmation | + +## Saved Customizations + +Lists every table where you set column widths, order, visibility, or a filter. Each row shows the table and what it saved (Columns, Filters), with **Reset** to return that table to defaults and **Reset All Customizations** to clear them all. You edit these inline in the grid and filter bar; this pane only reviews and resets them. diff --git a/docs/customization/editor-settings.mdx b/docs/customization/editor-settings.mdx index b5bd643bd..a1bb4bab6 100644 --- a/docs/customization/editor-settings.mdx +++ b/docs/customization/editor-settings.mdx @@ -1,13 +1,13 @@ --- title: Editor Settings -description: SQL editor, Vim mode, JSON viewer, data grid, pagination, and query result cap settings. +description: SQL editor, Vim mode, line numbers, indentation, and query parameter settings. --- # Editor Settings -Open **Settings** > **Editor** (`Cmd+,`). This tab holds the SQL editor toggles plus the Data Grid, Pagination, and Query Result Row Cap sections. +Open **Settings** > **Editor** (`Cmd+,`). This tab holds the SQL editor toggles. The data grid, pagination, query result cap, and JSON viewer moved to the [Data](/customization/data-settings) tab. -Editor and data grid fonts are per-theme. Edit them on the theme's Fonts tab under [Appearance](/customization/appearance). +Editor fonts are per-theme. Edit them on the theme's Fonts tab under [Appearance](/customization/appearance). Editor settings @@ -27,42 +27,3 @@ Editor and data grid fonts are per-theme. Edit them on the theme's Fonts tab und | Vim mode | Off | Modal editing in the SQL editor. See [Vim Mode](/features/vim-mode) | Autocomplete is always on; see [Autocomplete](/features/autocomplete). Editor shortcuts are listed and customized on [Keyboard Shortcuts](/features/keyboard-shortcuts). - -## JSON Viewer - -| Setting | Default | Options | -|---------|---------|---------| -| Default view | Text | Text, Tree | - -The mode the JSON viewer opens in. Switching modes inside the viewer writes the choice back to this setting. See [JSON Viewer](/features/json-viewer). - -## Data Grid - -| Setting | Default | Notes | -|---------|---------|-------| -| Row height | Normal | Compact, Normal, Comfortable, Spacious | -| Date format | ISO 8601 | ISO 8601, ISO Date, US Long, US Short, EU Long, EU Short | -| NULL display | `NULL` | Text shown for NULL cells, up to 20 characters | -| Show alternate row backgrounds | On | | -| Show row numbers | On | | -| Auto-show inspector on row select | Off | Opens the row details inspector when you select a row | -| Smart value detection | On | Renders UUID, Unix timestamp, JSON, and PHP serialized columns in readable form. Override per column with Display As, see [Data Grid](/features/data-grid) | -| Default row sort | No sorting (engine order) | Or Primary key, First column. Applied when a table first opens; clicking a column header overrides it | - -## Pagination - -| Setting | Default | Options | -|---------|---------|---------| -| Default page size | 1,000 rows | 100, 500, 1,000, 5,000, 10,000 | -| Count rows if estimate less than | 100,000 | 1,000; 10,000; 100,000; 1,000,000; Always count | - -Tables with more estimated rows than the threshold show an approximate count instead of running a slow `COUNT(*)`. - -## Query Result Row Cap - -| Setting | Default | Options | -|---------|---------|---------| -| Truncate query results | On | | -| Row cap | 10,000 | 100; 1,000; 5,000; 10,000; 50,000; 100,000; 500,000 | - -When on, `SELECT` queries without their own `LIMIT` run with the cap applied to the rows TablePro reads back. The SQL sent to the server is always the query you wrote. Capped results show a **Fetch All** button, and **Execute Without Limit** skips the cap for one run. diff --git a/docs/customization/settings.mdx b/docs/customization/settings.mdx index 50da75925..2f2392471 100644 --- a/docs/customization/settings.mdx +++ b/docs/customization/settings.mdx @@ -5,12 +5,13 @@ description: Every settings tab, its controls, and their defaults. # Settings -Open with `Cmd+,`. Settings are grouped into eight tabs. +Open with `Cmd+,`. Settings are grouped into nine tabs. - Language, startup, tabs, sidebar, query timeout, history, updates, reset. + Language, startup, tabs, sidebar, query timeout, updates, reset. Appearance mode, themes, per-theme fonts and colors. - SQL editor, Vim mode, JSON viewer, data grid, pagination. + SQL editor, Vim mode, line numbers, query parameters. + Data grid, pagination, result cap, JSON viewer, query history, saved customizations. Custom shortcuts. Providers, inline suggestions, context, slash commands. MCP server, tokens, remote access, activity. @@ -50,6 +51,7 @@ Reopened tabs restore their SQL, cursor position, sort, filters, page, and colum | Setting | Default | Description | |---------|---------|-------------| | **Show recent tables** | Off | Adds a Recent section at the top of the sidebar with the last 10 tables opened per connection and database | +| **Show object comments** | On | Shows database object comments next to tables in the sidebar and in grid column headers | | **Default layout for new connections** | List | List or Tree, for servers that support a database tree. Switch the current connection from the **View** menu | ### Query Execution @@ -70,15 +72,6 @@ Enforcement happens at the database level where supported: **No limit** raises the HTTP transport ceiling to 1 hour. -### Query History - -| Setting | Default | Description | -|---------|---------|-------------| -| **Maximum entries** | 10,000 | 100 to 10,000, or Unlimited | -| **Keep entries for** | 90 days | 7 days to 1 year, or Forever | -| **Auto cleanup on startup** | On | Remove old entries automatically | -| **Clear History...** | - | Wipe all history after confirmation | - ### Command Line **Install** writes a `tablepro` command to `/usr/local/bin` that opens database URLs in TablePro. If TablePro cannot write there, it shows a command for you to run in Terminal. See [Terminal and DDEV](/external-api/terminal). diff --git a/docs/docs.json b/docs/docs.json index 51b6cede3..fae25865c 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -177,7 +177,8 @@ "pages": [ "customization/settings", "customization/appearance", - "customization/editor-settings" + "customization/editor-settings", + "customization/data-settings" ] }, { diff --git a/docs/features/data-grid.mdx b/docs/features/data-grid.mdx index b9d710e55..490e44080 100644 --- a/docs/features/data-grid.mdx +++ b/docs/features/data-grid.mdx @@ -24,7 +24,7 @@ JSON mode shows the rows as a JSON array, with **Text** and **Tree** views. Sele Click a header to cycle through ascending, descending, and off. `Shift`-click another header to add it to the sort, so you can order by several columns at once. Sort re-runs the query with `ORDER BY` appended, replacing any existing one. -To sort on open, set **Settings > Editor > Default row sort** to **Primary key** or **First column** (default is **No sorting**). A click on any header still overrides it. +To sort on open, set **Settings > Data > Default row sort** to **Primary key** or **First column** (default is **No sorting**). A click on any header still overrides it. If the sort column can't be ordered on the server (a `BLOB`, `JSON`, or spatial column), the query fails. Pick another column or set the default back to No sorting. @@ -56,7 +56,7 @@ Column widths, order, and which columns are hidden are remembered per table, sco ### Display Format -UUIDs and Unix timestamps render in a readable form when the column type and name match (for example a `BINARY(16)` column named `uuid`). Right-click a header and choose **Display As** to set the format per column: Raw Value, UUID, Unix Timestamp (seconds or milliseconds), JSON, or PHP Serialized. See [Cell and Row Viewers](/features/json-viewer) for what each format does. Toggle the automatic detection in **Settings > Editor > Smart value detection**. +UUIDs and Unix timestamps render in a readable form when the column type and name match (for example a `BINARY(16)` column named `uuid`). Right-click a header and choose **Display As** to set the format per column: Raw Value, UUID, Unix Timestamp (seconds or milliseconds), JSON, or PHP Serialized. See [Cell and Row Viewers](/features/json-viewer) for what each format does. Toggle the automatic detection in **Settings > Data > Smart value detection**. ## Editing @@ -111,7 +111,7 @@ There is no discard button. Undo your edits, or let the confirmation prompt drop ## Inspector -Toggle the inspector with `Cmd+Option+I`. With a row selected, it lists every column value with full editors for long text and JSON; see [Cell and Row Viewers](/features/json-viewer) for details. Turn on **Settings > Editor > Auto-show inspector on row select** to open it automatically. +Toggle the inspector with `Cmd+Option+I`. With a row selected, it lists every column value with full editors for long text and JSON; see [Cell and Row Viewers](/features/json-viewer) for details. Turn on **Settings > Data > Auto-show inspector on row select** to open it automatically. With no row selected, it shows table statistics: data, index, and total size, row count, average row size, engine, collation, and created and updated dates (fields vary by database). @@ -143,17 +143,17 @@ Copies follow the grid as shown: hidden columns are left out and columns keep th ## Pagination and Limits -**Table tabs** page through data. The status bar has a rows-per-page menu (5 to 1,000, a custom size, or **All rows**) and First / Previous / Next / Last controls. Click the page indicator (for example `3 / 12`) to type a page number and jump to it. Set the default page size in **Settings > Editor**. +**Table tabs** page through data. The status bar has a rows-per-page menu (5 to 1,000, a custom size, or **All rows**) and First / Previous / Next / Last controls. Click the page indicator (for example `3 / 12`) to type a page number and jump to it. Set the default page size in **Settings > Data**. -Large tables show an estimated total instead of running a slow `COUNT(*)`. **Settings > Editor > Count rows if estimate less than** sets the threshold below which TablePro counts exactly. +Large tables show an estimated total instead of running a slow `COUNT(*)`. **Settings > Data > Count rows if estimate less than** sets the threshold below which TablePro counts exactly. -**Query tabs** cap results at 10,000 rows by default. For databases with `LIMIT` syntax, TablePro appends the cap to the statement it sends, so the server stops early; the query text in the editor never changes. A query with its own `LIMIT` or `FETCH FIRST` is sent as written and not capped. When the cap trims a result, the status bar shows **Fetch All** to load the rest (with a confirmation, since large results use a lot of memory). Run one query without the cap via **Execute Without Limit** (`Option+Cmd+Enter`). Adjust the cap in **Settings > Editor** (**Truncate query results**, **Row cap**). +**Query tabs** cap results at 10,000 rows by default. Your query is sent exactly as you wrote it; TablePro stops reading once it reaches the cap. A query with its own `LIMIT`, `FETCH FIRST`, or `TOP` is not capped. When the cap trims a result, the status bar shows **Fetch All** to load the rest (with a confirmation, since large results use a lot of memory). Run one query without the cap via **Execute Without Limit** (`Option+Cmd+Enter`). Adjust the cap in **Settings > Data** (**Truncate query results**, **Row cap**). Press `Cmd+.` to cancel a running query or a Fetch All. ## Display Settings -NULL shows as styled `NULL` text. Set the NULL display, date format, row height, row numbers, and alternate row backgrounds in **Settings > Editor**. +NULL shows as styled `NULL` text. Set the NULL display, date format, row height, row numbers, and alternate row backgrounds in **Settings > Data**. ## MongoDB Collections diff --git a/docs/features/query-history.mdx b/docs/features/query-history.mdx index 6d086ab95..e6355607c 100644 --- a/docs/features/query-history.mdx +++ b/docs/features/query-history.mdx @@ -39,7 +39,7 @@ Loading a query into the editor lets you edit it before running. The original hi History lives in `~/Library/Application Support/TablePro/query_history.db`, a local SQLite database with an FTS5 full-text index. -Configure retention in **Settings** > **General** > **Query History**: +Configure retention in **Settings** > **Data** > **Query History**: | Setting | Default | Options | |---------|---------|---------| diff --git a/docs/features/sql-editor.mdx b/docs/features/sql-editor.mdx index 8f06ec20e..fb634290e 100644 --- a/docs/features/sql-editor.mdx +++ b/docs/features/sql-editor.mdx @@ -68,7 +68,7 @@ Match modes: contains, matches word, starts with, ends with, or regular expressi SELECT and WITH queries without their own `LIMIT` or `FETCH FIRST` run with the configured row cap: -- The default cap is 10,000 rows. Pick 100 to 500,000 in **Settings > Editor** (**Row cap**), or turn off **Truncate query results** to disable it. +- The default cap is 10,000 rows. Pick 100 to 500,000 in **Settings > Data** (**Row cap**), or turn off **Truncate query results** to disable it. - Your query is sent exactly as you wrote it. TablePro never changes the SQL, it stops reading once it reaches the cap. - Your own `LIMIT`, `FETCH FIRST`, or `TOP` always wins: the query is not capped for that run. - `EXPLAIN`, `SHOW`, writes, and DDL are never limited. diff --git a/docs/features/tabs.mdx b/docs/features/tabs.mdx index e5947c3f4..5d2c8ccc2 100644 --- a/docs/features/tabs.mdx +++ b/docs/features/tabs.mdx @@ -84,7 +84,7 @@ Whether the last session reopens at launch is controlled by [Startup Behavior](/ ## Pagination -Table tabs load one page at a time. The default page size is 1,000 rows, set in **Settings > Editor > Pagination** (100, 500, 1,000, 5,000, or 10,000 rows). Query tabs are not paginated: **Truncate query results** (on by default) caps results at 10,000 rows, and queries with their own `LIMIT` are sent as written. Adjust the cap in the same settings pane. See [Data Grid](/features/data-grid#pagination-and-limits) for the pagination controls. +Table tabs load one page at a time. The default page size is 1,000 rows, set in **Settings > Data > Pagination** (100, 500, 1,000, 5,000, or 10,000 rows). Query tabs are not paginated: **Truncate query results** (on by default) caps results at 10,000 rows, and queries with their own `LIMIT` are sent as written. Adjust the cap in the same settings pane. See [Data Grid](/features/data-grid#pagination-and-limits) for the pagination controls. ## From External Clients