Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Removed the separate Copilot settings tab and the per-feature routing UI
- Existing AI providers are preserved on upgrade; the first one is auto-set as active
- Filter value field uses a native SwiftUI suggestion dropdown instead of the AppKit autocomplete popup
- MCP bridge now pins the server's TLS certificate fingerprint instead of accepting any certificate
- Replaced custom search field with native NSSearchField in keyboard shortcuts, database switcher, and quick switcher
- Column layout and filter state storage migrated from UserDefaults to file-based storage

### Added

Expand All @@ -32,6 +35,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- MCP server now shuts down reliably on app quit
- Improved keyboard and VoiceOver accessibility for interactive rows across the app
- Compiled theme fallback colors now match the default theme JSON files
- TablePlus import: correctly map all SSL/TLS modes instead of treating Prefer as disabled
- DBeaver import: parse SSL configuration from handler properties
- Sequel Ace import: read SSH port as number, not string
Expand All @@ -51,6 +57,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- DDL results showing misleading "0 row(s) affected"
- Export dialog missing empty state when no tables found
- Save-changes error messages, duplicated connection "(Copy)" suffix, query window title fallback, preview window subtitle, and inspector row count not localized
- Filter settings popover options not localized

### Changed

Expand Down
29 changes: 16 additions & 13 deletions TablePro/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -205,23 +205,26 @@ class AppDelegate: NSObject, NSApplicationDelegate {

func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
let hasUnsaved = MainContentCoordinator.hasAnyUnsavedChanges()
guard hasUnsaved else { return .terminateNow }

let alert = NSAlert()
alert.messageText = String(localized: "You have unsaved changes")
alert.informativeText = String(localized: "Some tabs have unsaved edits. Quitting will discard these changes.")
alert.alertStyle = .warning
alert.addButton(withTitle: String(localized: "Cancel"))
alert.addButton(withTitle: String(localized: "Quit Anyway"))
alert.buttons[1].hasDestructiveAction = true
let response = alert.runModal()
return response == .alertSecondButtonReturn ? .terminateNow : .terminateCancel
}
if hasUnsaved {
let alert = NSAlert()
alert.messageText = String(localized: "You have unsaved changes")
alert.informativeText = String(localized: "Some tabs have unsaved edits. Quitting will discard these changes.")
alert.alertStyle = .warning
alert.addButton(withTitle: String(localized: "Cancel"))
alert.addButton(withTitle: String(localized: "Quit Anyway"))
alert.buttons[1].hasDestructiveAction = true
let response = alert.runModal()
guard response == .alertSecondButtonReturn else { return .terminateCancel }
}

func applicationWillTerminate(_ notification: Notification) {
Task {
await MCPServerManager.shared.stop()
NSApp.reply(toApplicationShouldTerminate: true)
}
Comment on lines 220 to 223
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reply to termination from the main actor

NSApp.reply(toApplicationShouldTerminate:) is called inside an unannotated Task, which is not guaranteed to execute on the main actor. After await MCPServerManager.shared.stop() the continuation can resume off-main, and calling this AppKit API from a background executor can trigger main-thread violations or quit-flow instability.

Useful? React with 👍 / 👎.

return .terminateLater
}

func applicationWillTerminate(_ notification: Notification) {
LinkedFolderWatcher.shared.stop()
TerminalProcessManager.registry.terminateAllSync()
SSHTunnelManager.shared.terminateAllProcessesSync()
Expand Down
10 changes: 7 additions & 3 deletions TablePro/Core/MCP/MCPServerManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,8 @@ final class MCPServerManager {
allowRemoteAccess: settings.allowRemoteConnections,
tlsIdentity: tlsIdentity
)
writeHandshakeFile(port: port)
let certFingerprint = await tlsManager?.fingerprint
writeHandshakeFile(port: port, tlsCertFingerprint: certFingerprint)
startClientRefresh()
MCPAuditLogger.logServerStarted(
port: port,
Expand Down Expand Up @@ -186,17 +187,20 @@ final class MCPServerManager {
"\(handshakeDirectoryPath)/mcp-handshake.json"
}()

private func writeHandshakeFile(port: UInt16) {
private func writeHandshakeFile(port: UInt16, tlsCertFingerprint: String? = nil) {
guard let bridgeToken = internalBridgeToken else { return }

let settings = AppSettingsManager.shared.mcp
let handshake: [String: Any] = [
var handshake: [String: Any] = [
"port": Int(port),
"token": bridgeToken,
"pid": ProcessInfo.processInfo.processIdentifier,
"protocolVersion": "2025-03-26",
"tls": settings.allowRemoteConnections
]
if let tlsCertFingerprint {
handshake["tlsCertFingerprint"] = tlsCertFingerprint
}

let fileManager = FileManager.default
let directory = Self.handshakeDirectoryPath
Expand Down
169 changes: 150 additions & 19 deletions TablePro/Core/Storage/ColumnLayoutStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,41 @@
//

import Foundation
import os

@MainActor
internal final class ColumnLayoutStorage {
static let shared = ColumnLayoutStorage()

private init() {}

// MARK: - Types
private static let logger = Logger(subsystem: "com.TablePro", category: "ColumnLayoutStorage")
private static let legacyKeyPrefix = "com.TablePro.columns.layout."
private static let migrationCompleteKey = "com.TablePro.columnLayoutMigrationComplete"

private struct PersistedColumnLayout: Codable {
var columnWidths: [String: CGFloat]
var columnOrder: [String]?
}

// MARK: - Public API
private let storageDirectory: URL
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()

private var cache: [UUID: [String: PersistedColumnLayout]] = [:]

private init() {
storageDirectory = Self.resolvedStorageDirectory()

do {
try FileManager.default.createDirectory(
at: storageDirectory,
withIntermediateDirectories: true
)
} catch {
Self.logger.error("Failed to create storage directory: \(error.localizedDescription)")
}

Self.performMigrationIfNeeded(storageDirectory: storageDirectory)
}

func save(_ layout: ColumnLayoutState, for tableName: String, connectionId: UUID) {
guard !layout.columnWidths.isEmpty else { return }
Expand All @@ -27,33 +47,144 @@ internal final class ColumnLayoutStorage {
columnWidths: layout.columnWidths,
columnOrder: layout.columnOrder
)
let key = Self.userDefaultsKey(tableName: tableName, connectionId: connectionId)
if let data = try? JSONEncoder().encode(persisted) {
UserDefaults.standard.set(data, forKey: key)
}

var entries = loadEntries(for: connectionId)
entries[tableName] = persisted
cache[connectionId] = entries
writeEntries(entries, for: connectionId)
}

func load(for tableName: String, connectionId: UUID) -> ColumnLayoutState? {
let key = Self.userDefaultsKey(tableName: tableName, connectionId: connectionId)
guard let data = UserDefaults.standard.data(forKey: key),
let persisted = try? JSONDecoder().decode(PersistedColumnLayout.self, from: data)
else {
return nil
}
let entries = loadEntries(for: connectionId)
guard let persisted = entries[tableName] else { return nil }

var state = ColumnLayoutState()
state.columnWidths = persisted.columnWidths
state.columnOrder = persisted.columnOrder
return state
}

func clear(for tableName: String, connectionId: UUID) {
let key = Self.userDefaultsKey(tableName: tableName, connectionId: connectionId)
UserDefaults.standard.removeObject(forKey: key)
var entries = loadEntries(for: connectionId)
guard entries.removeValue(forKey: tableName) != nil else { return }

if entries.isEmpty {
cache[connectionId] = [:]
removeFile(for: connectionId)
} else {
cache[connectionId] = entries
writeEntries(entries, for: connectionId)
}
}

private func loadEntries(for connectionId: UUID) -> [String: PersistedColumnLayout] {
if let cached = cache[connectionId] { return cached }

let fileURL = fileURL(for: connectionId)
guard FileManager.default.fileExists(atPath: fileURL.path) else {
cache[connectionId] = [:]
return [:]
}

do {
let data = try Data(contentsOf: fileURL)
let entries = try decoder.decode([String: PersistedColumnLayout].self, from: data)
cache[connectionId] = entries
return entries
} catch {
Self.logger.error(
"Failed to load column layouts for \(connectionId): \(error.localizedDescription)"
)
cache[connectionId] = [:]
return [:]
}
}

// MARK: - Private
private func writeEntries(_ entries: [String: PersistedColumnLayout], for connectionId: UUID) {
let fileURL = fileURL(for: connectionId)
do {
let data = try encoder.encode(entries)
try data.write(to: fileURL, options: .atomic)
} catch {
Self.logger.error(
"Failed to write column layouts for \(connectionId): \(error.localizedDescription)"
)
}
}

private static func userDefaultsKey(tableName: String, connectionId: UUID) -> String {
"com.TablePro.columns.layout.\(connectionId.uuidString).\(tableName)"
private func removeFile(for connectionId: UUID) {
let fileURL = fileURL(for: connectionId)
guard FileManager.default.fileExists(atPath: fileURL.path) else { return }
do {
try FileManager.default.removeItem(at: fileURL)
} catch {
Self.logger.error(
"Failed to remove column layout file for \(connectionId): \(error.localizedDescription)"
)
}
}

private func fileURL(for connectionId: UUID) -> URL {
storageDirectory.appendingPathComponent("\(connectionId.uuidString).json")
}

private static func resolvedStorageDirectory() -> URL {
let appSupport = FileManager.default.urls(
for: .applicationSupportDirectory,
in: .userDomainMask
).first ?? FileManager.default.temporaryDirectory
return appSupport
.appendingPathComponent("TablePro", isDirectory: true)
.appendingPathComponent("ColumnLayout", isDirectory: true)
}

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

let allKeys = defaults.dictionaryRepresentation().keys
let legacyKeys = allKeys.filter { $0.hasPrefix(legacyKeyPrefix) }

var grouped: [UUID: [String: PersistedColumnLayout]] = [:]
let decoder = JSONDecoder()

for key in legacyKeys {
let suffix = String(key.dropFirst(legacyKeyPrefix.count))
guard let dotIndex = suffix.firstIndex(of: ".") else { continue }

let uuidString = String(suffix[..<dotIndex])
let tableName = String(suffix[suffix.index(after: dotIndex)...])

guard let connectionId = UUID(uuidString: uuidString),
let data = defaults.data(forKey: key),
let persisted = try? decoder.decode(PersistedColumnLayout.self, from: data) else {
defaults.removeObject(forKey: key)
continue
}

grouped[connectionId, default: [:]][tableName] = persisted
}

let encoder = JSONEncoder()
for (connectionId, entries) in grouped {
let fileURL = storageDirectory.appendingPathComponent("\(connectionId.uuidString).json")
do {
let data = try encoder.encode(entries)
try data.write(to: fileURL, options: .atomic)
} catch {
logger.error(
"Migration failed for \(connectionId): \(error.localizedDescription)"
)
}
}

for key in legacyKeys {
defaults.removeObject(forKey: key)
}
defaults.set(true, forKey: migrationCompleteKey)
Comment on lines +181 to +184
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve legacy layouts when migration write fails

The migration unconditionally deletes every legacy UserDefaults key and marks migration complete even if writing the new JSON files failed earlier (for example, due to disk-full or permission errors). In that failure path, users lose all saved column layouts and the app will not retry migration on next launch because migrationCompleteKey is already set.

Useful? React with 👍 / 👎.


if !grouped.isEmpty {
logger.trace("Migrated \(grouped.count) connection(s) of column layouts to file storage")
}
}
}
Loading
Loading