Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- 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 Settings window is reorganized into a searchable sidebar: a Data & Results section groups the data grid, pagination, result limits, formatting, JSON viewer, and query history; a Sidebar section groups recent tables, default layout, and object comments.

### Fixed

- 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.56.1] - 2026-07-09

### Removed
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
2 changes: 2 additions & 0 deletions TablePro/Core/Storage/AppSettingsStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,8 @@ final class AppSettingsStorage {
saveAI(.default)
saveSync(.default)
saveMCP(.default)
defaults.removeObject(forKey: PreferenceKeys.selectedSettingsPane.name)
defaults.removeObject(forKey: SidebarPersistenceKey.defaultLayout)
}

// MARK: - Helpers
Expand Down
Loading
Loading