Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
9339f9d
refactor(storage): add typed preferences foundation and back linked-f…
datlechin Jul 9, 2026
a8166de
fix(datagrid): scope per-column display formats by database and schema
datlechin Jul 9, 2026
2e548f2
refactor(datagrid): consolidate column geometry and visibility into o…
datlechin Jul 9, 2026
0d84c40
fix(filter): persist the AND/OR match mode so it survives reopening a…
datlechin Jul 9, 2026
1bfd03f
fix(filter): add PersistedFilterState model behind persisted match mode
datlechin Jul 9, 2026
1c607c6
refactor(filter): move raw-SQL safety into SQLBoundaryValidator
datlechin Jul 9, 2026
92790cc
feat(settings): add Data & Results and Sidebar tabs to the settings w…
datlechin Jul 9, 2026
aee2307
refactor(settings): drive tabs from a SettingsPane registry and names…
datlechin Jul 9, 2026
3efa8f1
feat(sidebar): persist database-tree expansion per connection across …
datlechin Jul 9, 2026
18a7629
refactor(theme): drop AppSettingsManager's duplicate accessibility ob…
datlechin Jul 9, 2026
3b2b55a
refactor(settings): clear selected-pane and default-layout on Reset A…
datlechin Jul 9, 2026
63c9421
feat(settings): searchable sidebar navigation for the settings window
datlechin Jul 9, 2026
40d6f2a
feat(settings): edit light and dark themes independently without swit…
datlechin Jul 9, 2026
30c87a4
refactor(sync): route markDirty through a declared per-record SyncScope
datlechin Jul 9, 2026
9cb46f4
refactor(storage): share the Keychain read-result mapping across secr…
datlechin Jul 9, 2026
4ad816c
feat(settings): add a Saved Customizations pane to review and reset p…
datlechin Jul 9, 2026
dfc0b68
refactor(theme): remove the unused accentColor theme field
datlechin Jul 9, 2026
071ba93
feat(sync): sync per-table column layouts via iCloud
datlechin Jul 9, 2026
d3b5326
refactor(sidebar): unify search text and Redis tree into SharedSideba…
datlechin Jul 9, 2026
f5be7e2
Merge branch 'main' into refactor/settings-unification
datlechin Jul 11, 2026
69dd1f1
refactor(datagrid): route the row inspector JSON field height through…
datlechin Jul 11, 2026
35365c8
refactor(settings): restyle the settings window to match macOS System…
datlechin Jul 11, 2026
30e7735
Merge branch 'main' into refactor/settings-unification
datlechin Jul 15, 2026
f68888e
Merge branch 'main' into refactor/settings-unification
datlechin Jul 17, 2026
File filter

Filter by extension

Filter by extension

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

Expand All @@ -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

Expand Down
18 changes: 14 additions & 4 deletions TablePro/Core/Coordinators/FilterCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -459,25 +461,31 @@ 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,
schemaName: tab.tableContext.schemaName
)
}
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
Expand All @@ -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
}
Expand Down
30 changes: 1 addition & 29 deletions TablePro/Core/Database/FilterSQLGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))"
}

Expand Down Expand Up @@ -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] {
Expand Down
34 changes: 34 additions & 0 deletions TablePro/Core/Database/SQLBoundaryValidator.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
46 changes: 19 additions & 27 deletions TablePro/Core/Services/Formatting/ValueDisplayFormatService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -41,59 +40,52 @@ 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

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
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions TablePro/Core/Services/Infrastructure/WindowOpener.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?() }
}
Expand Down
21 changes: 2 additions & 19 deletions TablePro/Core/Storage/AIKeyStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
29 changes: 0 additions & 29 deletions TablePro/Core/Storage/AppSettingsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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() }
}
Expand All @@ -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()
}
Expand Down
16 changes: 16 additions & 0 deletions TablePro/Core/Storage/AppSettingsStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading