From e9dc02875502714cb1fc2777ea388a17c48ce6df Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 27 Apr 2026 00:57:28 +0700 Subject: [PATCH 01/10] fix: ensure MCP server stops reliably on app termination --- TablePro/AppDelegate.swift | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index f2292b171..c98ce24e7 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -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) } + return .terminateLater + } + + func applicationWillTerminate(_ notification: Notification) { LinkedFolderWatcher.shared.stop() TerminalProcessManager.registry.terminateAllSync() SSHTunnelManager.shared.terminateAllProcessesSync() From 60f25a50106daee7dda28e82fa8f7d607f9ec55f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 27 Apr 2026 00:57:36 +0700 Subject: [PATCH 02/10] fix: pin TLS certificate fingerprint in MCP bridge, fix JSON-RPC error serialization --- TablePro/Core/MCP/MCPServerManager.swift | 10 +++- TablePro/MCPBridge/MCPBridgeProxy.swift | 76 +++++++++++++++++------- 2 files changed, 60 insertions(+), 26 deletions(-) diff --git a/TablePro/Core/MCP/MCPServerManager.swift b/TablePro/Core/MCP/MCPServerManager.swift index 3a2f2c691..ddbbc437a 100644 --- a/TablePro/Core/MCP/MCPServerManager.swift +++ b/TablePro/Core/MCP/MCPServerManager.swift @@ -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, @@ -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 diff --git a/TablePro/MCPBridge/MCPBridgeProxy.swift b/TablePro/MCPBridge/MCPBridgeProxy.swift index c01ccfe5c..2cbfec99e 100644 --- a/TablePro/MCPBridge/MCPBridgeProxy.swift +++ b/TablePro/MCPBridge/MCPBridgeProxy.swift @@ -1,4 +1,6 @@ +import CryptoKit import Foundation +import Security struct MCPHandshake: Codable { let port: Int @@ -6,9 +8,16 @@ struct MCPHandshake: Codable { let pid: Int32 let protocolVersion: String let tls: Bool? + let tlsCertFingerprint: String? } -private final class TrustAllDelegate: NSObject, URLSessionDelegate { +private final class CertificatePinningDelegate: NSObject, URLSessionDelegate { + private let expectedFingerprint: String + + init(expectedFingerprint: String) { + self.expectedFingerprint = expectedFingerprint + } + func urlSession( _ session: URLSession, didReceive challenge: URLAuthenticationChallenge @@ -17,24 +26,36 @@ private final class TrustAllDelegate: NSObject, URLSessionDelegate { let trust = challenge.protectionSpace.serverTrust else { return (.performDefaultHandling, nil) } + + guard let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate], + let serverCert = chain.first else { + return (.cancelAuthenticationChallenge, nil) + } + + let serverFingerprint = sha256Fingerprint(of: serverCert) + guard serverFingerprint == expectedFingerprint else { + return (.cancelAuthenticationChallenge, nil) + } + return (.useCredential, URLCredential(trust: trust)) } + + private func sha256Fingerprint(of certificate: SecCertificate) -> String { + let data = SecCertificateCopyData(certificate) as Data + return SHA256.hash(data: data) + .map { String(format: "%02X", $0) } + .joined(separator: ":") + } } final class MCPBridgeProxy { private let handshakePath: String private var sessionId: String? - private let urlSession: URLSession - private let sessionDelegate = TrustAllDelegate() + private var urlSession: URLSession! init() { let home = FileManager.default.homeDirectoryForCurrentUser.path self.handshakePath = "\(home)/Library/Application Support/com.TablePro/mcp-handshake.json" - - let config = URLSessionConfiguration.ephemeral - config.timeoutIntervalForRequest = 60 - config.timeoutIntervalForResource = 60 - self.urlSession = URLSession(configuration: config, delegate: sessionDelegate, delegateQueue: nil) } func run() async { @@ -62,6 +83,18 @@ final class MCPBridgeProxy { exit(1) } + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = 60 + config.timeoutIntervalForResource = 60 + + let delegate: URLSessionDelegate? + if handshake.tls ?? false, let fingerprint = handshake.tlsCertFingerprint { + delegate = CertificatePinningDelegate(expectedFingerprint: fingerprint) + } else { + delegate = nil + } + self.urlSession = URLSession(configuration: config, delegate: delegate, delegateQueue: nil) + let scheme = (handshake.tls ?? false) ? "https" : "http" let baseUrl = "\(scheme)://127.0.0.1:\(handshake.port)/mcp" let bearerToken = handshake.token @@ -149,8 +182,6 @@ final class MCPBridgeProxy { } private func captureSessionId(from response: HTTPURLResponse) { - guard sessionId == nil else { return } - let headerFields = response.allHeaderFields for (key, value) in headerFields { guard let keyString = key as? String else { continue } @@ -183,25 +214,24 @@ final class MCPBridgeProxy { } private func writeJsonRpcError(id: JsonRpcId, code: Int, message: String) { - let idJson: String + var errorResponse: [String: Any] = [ + "jsonrpc": "2.0", + "error": [ + "code": code, + "message": message + ] as [String: Any] + ] + switch id { case .null: - idJson = "null" + errorResponse["id"] = NSNull() case .int(let value): - idJson = "\(value)" + errorResponse["id"] = value case .string(let value): - let escaped = value - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - idJson = "\"\(escaped)\"" + errorResponse["id"] = value } - let escapedMessage = message - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - - let json = "{\"jsonrpc\":\"2.0\",\"id\":\(idJson),\"error\":{\"code\":\(code),\"message\":\"\(escapedMessage)\"}}" - guard let data = json.data(using: .utf8) else { return } + guard let data = try? JSONSerialization.data(withJSONObject: errorResponse) else { return } writeStdout(data) writeStdout(Data([0x0A])) } From 0e9d5025a478636a06f2e87b081f6b004cdf5f62 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 27 Apr 2026 00:51:08 +0700 Subject: [PATCH 03/10] fix: align compiled theme defaults with JSON, replace Google Material colors with Apple system --- CHANGELOG.md | 1 + TablePro/Theme/ThemeColors.swift | 49 ++++++++++++++++---------------- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3dfb8dd6..a7b3028ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- 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 diff --git a/TablePro/Theme/ThemeColors.swift b/TablePro/Theme/ThemeColors.swift index 02148f41a..90ec9013a 100644 --- a/TablePro/Theme/ThemeColors.swift +++ b/TablePro/Theme/ThemeColors.swift @@ -18,11 +18,11 @@ internal struct SyntaxColors: Codable, Equatable, Sendable { var type: String static let defaultLight = SyntaxColors( - keyword: "#9B2393", + keyword: "#0A49A5", string: "#C41A16", - number: "#1C00CF", - comment: "#5D6C79", - null: "#9B2393", + number: "#6C36A9", + comment: "#007400", + null: "#C55B00", operator: "#000000", function: "#326D74", type: "#3F6E74" @@ -73,18 +73,17 @@ internal struct EditorThemeColors: Codable, Equatable, Sendable { var selection: String var lineNumber: String var invisibles: String - /// Reserved for future current-statement background highlight in the query editor. var currentStatementHighlight: String var syntax: SyntaxColors static let defaultLight = EditorThemeColors( background: "#FFFFFF", text: "#000000", - cursor: "#000000", - currentLineHighlight: "#ECF5FF", + cursor: "#007AFF", + currentLineHighlight: "#007AFF14", selection: "#B4D8FD", - lineNumber: "#747478", - invisibles: "#D6D6D6", + lineNumber: "#8E8E93", + invisibles: "#C7C7CC", currentStatementHighlight: "#F0F4FA", syntax: .defaultLight ) @@ -149,15 +148,15 @@ internal struct DataGridThemeColors: Codable, Equatable, Sendable { background: "#FFFFFF", text: "#000000", alternateRow: "#F5F5F5", - nullValue: "#B0B0B0", - boolTrue: "#34A853", - boolFalse: "#EA4335", - rowNumber: "#747478", - modified: "#FFF9C4", - inserted: "#E8F5E9", - deleted: "#FFEBEE", - deletedText: "#B0B0B0", - focusBorder: "#2196F3" + nullValue: "#8E8E93", + boolTrue: "#248A3D", + boolFalse: "#D70015", + rowNumber: "#8E8E93", + modified: "#FFD60A4D", + inserted: "#34C7594D", + deleted: "#FF3B304D", + deletedText: "#FF3B3080", + focusBorder: "#007AFF" ) init( @@ -216,10 +215,10 @@ internal struct StatusColors: Codable, Equatable, Sendable { var info: String static let defaultLight = StatusColors( - success: "#34A853", - warning: "#FBBC04", - error: "#EA4335", - info: "#4285F4" + success: "#248A3D", + warning: "#C55B00", + error: "#D70015", + info: "#007AFF" ) init(success: String, warning: String, error: String, info: String) { @@ -248,9 +247,9 @@ internal struct BadgeColors: Codable, Equatable, Sendable { var autoIncrement: String static let defaultLight = BadgeColors( - background: "#E8E8ED", - primaryKey: "#FFCC00", - autoIncrement: "#AF52DE" + background: "#E5E5EA", + primaryKey: "#007AFF26", + autoIncrement: "#AF52DE26" ) init(background: String, primaryKey: String, autoIncrement: String) { From cf3501959a4c241e34a589d080e7fcfff13bedde Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 27 Apr 2026 00:54:00 +0700 Subject: [PATCH 04/10] refactor: replace custom SearchFieldView with native NSSearchField --- CHANGELOG.md | 2 + .../Views/Components/SearchFieldView.swift | 37 ------------------- .../DatabaseSwitcherSheet.swift | 18 ++------- .../QuickSwitcher/QuickSwitcherView.swift | 19 +++------- .../Views/Settings/KeyboardSettingsView.swift | 7 ++-- .../Views/Sidebar/NativeSearchField.swift | 18 +++++++++ 6 files changed, 32 insertions(+), 69 deletions(-) delete mode 100644 TablePro/Views/Components/SearchFieldView.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index a7b3028ae..432815220 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ 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 +- Replaced custom search field with native NSSearchField in keyboard shortcuts, database switcher, and quick switcher ### Added @@ -32,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- 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 diff --git a/TablePro/Views/Components/SearchFieldView.swift b/TablePro/Views/Components/SearchFieldView.swift deleted file mode 100644 index 0b55b5c33..000000000 --- a/TablePro/Views/Components/SearchFieldView.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// SearchFieldView.swift -// TablePro -// - -import SwiftUI - -struct SearchFieldView: View { - let placeholder: String - @Binding var text: String - var fontSize: CGFloat? - - var body: some View { - let resolvedSize = fontSize ?? 13 - HStack(spacing: 6) { - Image(systemName: "magnifyingglass") - .font(.system(size: resolvedSize)) - .foregroundStyle(.tertiary) - - TextField(placeholder, text: $text) - .textFieldStyle(.plain) - .font(.system(size: resolvedSize)) - - if !text.isEmpty { - Button { text = "" } label: { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - } - } - .padding(.horizontal, 8) - .padding(.vertical, 6) - .background(Color(nsColor: .controlBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 6)) - } -} diff --git a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift index 2ef2d0296..4dbd0e1f8 100644 --- a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift +++ b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift @@ -27,7 +27,6 @@ struct DatabaseSwitcherSheet: View { @State private var databaseToDrop: String? private enum FocusField { - case search case databaseList } @@ -112,7 +111,6 @@ struct DatabaseSwitcherSheet: View { ? String(localized: "Open Schema") : String(localized: "Open Database")) .background(Color(nsColor: .windowBackgroundColor)) - .defaultFocus($focus, .search) .task { await viewModel.fetchDatabases() } .sheet(isPresented: $showCreateDialog) { CreateDatabaseSheet(databaseType: databaseType, viewModel: viewModel) @@ -153,22 +151,14 @@ struct DatabaseSwitcherSheet: View { private var toolbar: some View { HStack(spacing: 8) { - // Search - SearchFieldView( + NativeSearchField( + text: $viewModel.searchText, placeholder: isSchemaMode ? String(localized: "Search schemas...") : String(localized: "Search databases..."), - text: $viewModel.searchText + onMoveUp: { viewModel.moveUp() }, + onMoveDown: { viewModel.moveDown() } ) - .focused($focus, equals: .search) - .onKeyPress(.upArrow) { - viewModel.moveUp() - return .handled - } - .onKeyPress(.downArrow) { - viewModel.moveDown() - return .handled - } // Refresh Button(action: { diff --git a/TablePro/Views/QuickSwitcher/QuickSwitcherView.swift b/TablePro/Views/QuickSwitcher/QuickSwitcherView.swift index 0281640f4..286786768 100644 --- a/TablePro/Views/QuickSwitcher/QuickSwitcherView.swift +++ b/TablePro/Views/QuickSwitcher/QuickSwitcherView.swift @@ -23,7 +23,6 @@ internal struct QuickSwitcherSheet: View { @State private var viewModel = QuickSwitcherViewModel() private enum FocusField { - case search case itemList } @@ -66,7 +65,6 @@ internal struct QuickSwitcherSheet: View { databaseType: databaseType ) } - .defaultFocus($focus, .search) .onExitCommand { dismiss() } .onKeyPress(.return) { openSelectedItem() @@ -87,19 +85,12 @@ internal struct QuickSwitcherSheet: View { // MARK: - Search Toolbar private var searchToolbar: some View { - SearchFieldView( - placeholder: "Search tables, views, databases...", - text: $viewModel.searchText + NativeSearchField( + text: $viewModel.searchText, + placeholder: String(localized: "Search tables, views, databases..."), + onMoveUp: { viewModel.moveUp() }, + onMoveDown: { viewModel.moveDown() } ) - .focused($focus, equals: .search) - .onKeyPress(.upArrow) { - viewModel.moveUp() - return .handled - } - .onKeyPress(.downArrow) { - viewModel.moveDown() - return .handled - } .padding(.horizontal, 12) .padding(.vertical, 8) } diff --git a/TablePro/Views/Settings/KeyboardSettingsView.swift b/TablePro/Views/Settings/KeyboardSettingsView.swift index cda06e090..1ce07bb0c 100644 --- a/TablePro/Views/Settings/KeyboardSettingsView.swift +++ b/TablePro/Views/Settings/KeyboardSettingsView.swift @@ -18,10 +18,9 @@ struct KeyboardSettingsView: View { var body: some View { VStack(spacing: 0) { - // Search bar - SearchFieldView( - placeholder: "Search shortcuts...", - text: $searchText + NativeSearchField( + text: $searchText, + placeholder: String(localized: "Search shortcuts...") ) .padding(.horizontal, 20) .padding(.top, 16) diff --git a/TablePro/Views/Sidebar/NativeSearchField.swift b/TablePro/Views/Sidebar/NativeSearchField.swift index c69a7df3a..bc10a0a85 100644 --- a/TablePro/Views/Sidebar/NativeSearchField.swift +++ b/TablePro/Views/Sidebar/NativeSearchField.swift @@ -12,6 +12,8 @@ struct NativeSearchField: NSViewRepresentable { @Binding var text: String var placeholder: String var controlSize: NSControl.ControlSize = .regular + var onMoveUp: (() -> Void)? + var onMoveDown: (() -> Void)? func makeNSView(context: Context) -> NSSearchField { let field = NSSearchField() @@ -28,6 +30,8 @@ struct NativeSearchField: NSViewRepresentable { field.stringValue = text } field.placeholderString = placeholder + context.coordinator.onMoveUp = onMoveUp + context.coordinator.onMoveDown = onMoveDown } func makeCoordinator() -> Coordinator { @@ -36,6 +40,8 @@ struct NativeSearchField: NSViewRepresentable { final class Coordinator: NSObject, NSSearchFieldDelegate { var text: Binding + var onMoveUp: (() -> Void)? + var onMoveDown: (() -> Void)? init(text: Binding) { self.text = text @@ -50,5 +56,17 @@ struct NativeSearchField: NSViewRepresentable { text.wrappedValue = "" sender.window?.makeFirstResponder(nil) } + + func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + if commandSelector == #selector(NSResponder.moveUp(_:)), let onMoveUp { + onMoveUp() + return true + } + if commandSelector == #selector(NSResponder.moveDown(_:)), let onMoveDown { + onMoveDown() + return true + } + return false + } } } From 980ad674b1da3996f1425387dcbe8f78c2908341 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 27 Apr 2026 00:54:40 +0700 Subject: [PATCH 05/10] fix: replace onTapGesture with Button for keyboard accessibility, remove iOS row height --- .../Views/Connection/WelcomeWindowView.swift | 1 - .../Views/Filter/FilterValueTextField.swift | 35 +++++++------- .../Results/EnumPopoverContentView.swift | 15 +++--- .../ForeignKeyPopoverContentView.swift | 21 ++++---- TablePro/Views/Settings/AISettingsView.swift | 32 +++++++------ .../Views/Settings/ThemePreviewCard.swift | 47 +++++++++--------- TablePro/Views/Sidebar/RedisKeyTreeView.swift | 48 ++++++++++--------- .../Structure/TypePickerContentView.swift | 15 +++--- 8 files changed, 111 insertions(+), 103 deletions(-) diff --git a/TablePro/Views/Connection/WelcomeWindowView.swift b/TablePro/Views/Connection/WelcomeWindowView.swift index 72ba80792..c207e77f2 100644 --- a/TablePro/Views/Connection/WelcomeWindowView.swift +++ b/TablePro/Views/Connection/WelcomeWindowView.swift @@ -274,7 +274,6 @@ struct WelcomeWindowView: View { .listStyle(.inset) .scrollContentBackground(.hidden) .focused($focus, equals: .connectionList) - .environment(\.defaultMinListRowHeight, 44) .onKeyPress(.return) { vm.connectSelectedConnections() return .handled diff --git a/TablePro/Views/Filter/FilterValueTextField.swift b/TablePro/Views/Filter/FilterValueTextField.swift index 1a26ada4a..09c084fac 100644 --- a/TablePro/Views/Filter/FilterValueTextField.swift +++ b/TablePro/Views/Filter/FilterValueTextField.swift @@ -306,23 +306,24 @@ struct FilterValueTextField: NSViewRepresentable { ScrollView { VStack(alignment: .leading, spacing: 0) { ForEach(Array(state.items.enumerated()), id: \.offset) { index, item in - Text(item) - .font(.callout) - .lineLimit(1) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 6) - .padding(.vertical, 3) - .background( - state.selectedIndex == index - ? Color.accentColor.opacity(0.18) - : Color.clear - ) - .cornerRadius(4) - .contentShape(Rectangle()) - .id(index) - .onTapGesture { - onSelect(item) - } + Button { + onSelect(item) + } label: { + Text(item) + .font(.callout) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background( + state.selectedIndex == index + ? Color.accentColor.opacity(0.18) + : Color.clear + ) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } + .buttonStyle(.plain) + .id(index) } } .padding(4) diff --git a/TablePro/Views/Results/EnumPopoverContentView.swift b/TablePro/Views/Results/EnumPopoverContentView.swift index cf24dbc69..b198e2ba8 100644 --- a/TablePro/Views/Results/EnumPopoverContentView.swift +++ b/TablePro/Views/Results/EnumPopoverContentView.swift @@ -45,13 +45,14 @@ struct EnumPopoverContentView: View { List { ForEach(filteredValues, id: \.self) { value in - rowLabel(for: value) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - .onTapGesture { commitValue(value) } - .listRowInsets(EdgeInsets( - top: 2, leading: 6, bottom: 2, trailing: 6 - )) + Button { commitValue(value) } label: { + rowLabel(for: value) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + .listRowInsets(EdgeInsets( + top: 2, leading: 6, bottom: 2, trailing: 6 + )) } } .listStyle(.plain) diff --git a/TablePro/Views/Results/ForeignKeyPopoverContentView.swift b/TablePro/Views/Results/ForeignKeyPopoverContentView.swift index 90b6321f0..331c85f5c 100644 --- a/TablePro/Views/Results/ForeignKeyPopoverContentView.swift +++ b/TablePro/Views/Results/ForeignKeyPopoverContentView.swift @@ -61,16 +61,17 @@ struct ForeignKeyPopoverContentView: View { .frame(height: 60) } else { List(filteredValues, selection: $selectedId) { value in - rowLabel(for: value) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - .onTapGesture { - onCommit(value.id) - onDismiss() - } - .listRowInsets(EdgeInsets( - top: 2, leading: 6, bottom: 2, trailing: 6 - )) + Button { + onCommit(value.id) + onDismiss() + } label: { + rowLabel(for: value) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + .listRowInsets(EdgeInsets( + top: 2, leading: 6, bottom: 2, trailing: 6 + )) } .listStyle(.plain) .environment(\.defaultMinListRowHeight, Self.rowHeight) diff --git a/TablePro/Views/Settings/AISettingsView.swift b/TablePro/Views/Settings/AISettingsView.swift index 251712266..40e567181 100644 --- a/TablePro/Views/Settings/AISettingsView.swift +++ b/TablePro/Views/Settings/AISettingsView.swift @@ -118,24 +118,26 @@ struct AISettingsView: View { emptyProvidersRow } else { ForEach(settings.providers) { provider in - providerRow(provider) - .contentShape(Rectangle()) - .onTapGesture { + Button { + editingProviderID = provider.id + } label: { + providerRow(provider) + } + .buttonStyle(.plain) + .contentShape(Rectangle()) + .contextMenu { + Button(String(localized: "Edit")) { editingProviderID = provider.id } - .contextMenu { - Button(String(localized: "Edit")) { - editingProviderID = provider.id - } - Button(String(localized: "Set as Active")) { - settings.activeProviderID = provider.id - } - .disabled(settings.activeProviderID == provider.id) - Divider() - Button(String(localized: "Remove"), role: .destructive) { - pendingDeleteID = provider.id - } + Button(String(localized: "Set as Active")) { + settings.activeProviderID = provider.id + } + .disabled(settings.activeProviderID == provider.id) + Divider() + Button(String(localized: "Remove"), role: .destructive) { + pendingDeleteID = provider.id } + } } } addProviderMenu diff --git a/TablePro/Views/Settings/ThemePreviewCard.swift b/TablePro/Views/Settings/ThemePreviewCard.swift index 78f8b3dc5..da7ed4a27 100644 --- a/TablePro/Views/Settings/ThemePreviewCard.swift +++ b/TablePro/Views/Settings/ThemePreviewCard.swift @@ -30,32 +30,33 @@ struct ThemePreviewCard: View { // MARK: - Standard Card private var standardCard: some View { - VStack(spacing: 4) { - thumbnail - .frame(width: 160, height: 100) - .clipShape(RoundedRectangle(cornerRadius: 6)) - .overlay( - RoundedRectangle(cornerRadius: 6) - .strokeBorder(isActive ? Color.accentColor : Color.clear, lineWidth: 2.5) - ) - .shadow(color: .black.opacity(0.1), radius: 2, y: 1) - - VStack(spacing: 1) { - Text(theme.name) - .font(.subheadline) - .lineLimit(1) - .foregroundStyle(.primary) - - Text(theme.isBuiltIn - ? String(localized: "Built-in") - : String(localized: "Custom")) - .font(.system(size: 9)) - .foregroundStyle(.secondary) + Button(action: onSelect) { + VStack(spacing: 4) { + thumbnail + .frame(width: 160, height: 100) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(isActive ? Color.accentColor : Color.clear, lineWidth: 2.5) + ) + .shadow(color: .black.opacity(0.1), radius: 2, y: 1) + + VStack(spacing: 1) { + Text(theme.name) + .font(.subheadline) + .lineLimit(1) + .foregroundStyle(.primary) + + Text(theme.isBuiltIn + ? String(localized: "Built-in") + : String(localized: "Custom")) + .font(.system(size: 9)) + .foregroundStyle(.secondary) + } } } + .buttonStyle(.plain) .frame(width: 160) - .contentShape(Rectangle()) - .onTapGesture(perform: onSelect) } // MARK: - Compact Card diff --git a/TablePro/Views/Sidebar/RedisKeyTreeView.swift b/TablePro/Views/Sidebar/RedisKeyTreeView.swift index 5e50c5260..4f86a10a0 100644 --- a/TablePro/Views/Sidebar/RedisKeyTreeView.swift +++ b/TablePro/Views/Sidebar/RedisKeyTreeView.swift @@ -66,36 +66,38 @@ internal struct RedisKeyTreeView: View { } private func namespaceLabel(name: String, keyCount: Int, fullPrefix: String) -> some View { - HStack { - Label(name, systemImage: "folder") - .foregroundStyle(.primary) - Spacer() - Text("\(keyCount)") - .font(.caption2) - .foregroundStyle(.secondary) - .padding(.horizontal, 5) - .padding(.vertical, 1) - .background(.quaternary, in: Capsule()) - } - .contentShape(Rectangle()) - .onTapGesture { + Button { onSelectNamespace?(fullPrefix) + } label: { + HStack { + Label(name, systemImage: "folder") + .foregroundStyle(.primary) + Spacer() + Text("\(keyCount)") + .font(.caption2) + .foregroundStyle(.secondary) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(.quaternary, in: Capsule()) + } } + .buttonStyle(.plain) } private func keyLabel(name: String, fullKey: String, keyType: String) -> some View { - HStack { - Label(name, systemImage: keyTypeIcon(keyType)) - .foregroundStyle(.primary) - Spacer() - Text(keyType) - .font(.caption2) - .foregroundStyle(.tertiary) - } - .contentShape(Rectangle()) - .onTapGesture { + Button { onSelectKey?(fullKey, keyType) + } label: { + HStack { + Label(name, systemImage: keyTypeIcon(keyType)) + .foregroundStyle(.primary) + Spacer() + Text(keyType) + .font(.caption2) + .foregroundStyle(.tertiary) + } } + .buttonStyle(.plain) } private func keyTypeIcon(_ type: String) -> String { diff --git a/TablePro/Views/Structure/TypePickerContentView.swift b/TablePro/Views/Structure/TypePickerContentView.swift index 8387fff59..7ef288e80 100644 --- a/TablePro/Views/Structure/TypePickerContentView.swift +++ b/TablePro/Views/Structure/TypePickerContentView.swift @@ -65,13 +65,14 @@ struct TypePickerContentView: View { ForEach(visibleCategories, id: \.name) { category in Section(header: Text(category.name)) { ForEach(category.types, id: \.self) { type in - typeRow(type) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - .onTapGesture { commitType(type) } - .listRowInsets(EdgeInsets( - top: 2, leading: 6, bottom: 2, trailing: 6 - )) + Button { commitType(type) } label: { + typeRow(type) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + .listRowInsets(EdgeInsets( + top: 2, leading: 6, bottom: 2, trailing: 6 + )) } } } From 4089ffa644af3086eadef3bd332cb7c21c50c2d6 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 27 Apr 2026 00:58:29 +0700 Subject: [PATCH 06/10] chore: consolidate CHANGELOG entries for HIG audit fixes --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 432815220..3c043be29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ 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 ### Added @@ -33,6 +34,7 @@ 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 From bc83647ca659aac7fe53abff500e8a6a1a090650 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 27 Apr 2026 01:05:32 +0700 Subject: [PATCH 07/10] fix: localize non-SwiftUI user-facing strings for i18n support --- CHANGELOG.md | 1 + TablePro/Core/Storage/FilterSettingsStorage.swift | 10 +++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c043be29..d81da1e96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,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 diff --git a/TablePro/Core/Storage/FilterSettingsStorage.swift b/TablePro/Core/Storage/FilterSettingsStorage.swift index 202a025c4..a2c0115e8 100644 --- a/TablePro/Core/Storage/FilterSettingsStorage.swift +++ b/TablePro/Core/Storage/FilterSettingsStorage.swift @@ -19,8 +19,8 @@ enum FilterDefaultColumn: String, CaseIterable, Identifiable, Codable { var displayName: String { switch self { case .rawSQL: return "Raw SQL" - case .primaryKey: return "Primary Key" - case .anyColumn: return "Any Column" + case .primaryKey: return String(localized: "Primary Key") + case .anyColumn: return String(localized: "Any Column") } } } @@ -56,9 +56,9 @@ enum FilterPanelDefaultState: String, CaseIterable, Identifiable, Codable { var displayName: String { switch self { - case .restoreLast: return "Restore Last Filter" - case .alwaysShow: return "Always Show" - case .alwaysHide: return "Always Hide" + case .restoreLast: return String(localized: "Restore Last Filter") + case .alwaysShow: return String(localized: "Always Show") + case .alwaysHide: return String(localized: "Always Hide") } } } From e7843e8dff6a3830a3e754fa2193783d8d648a59 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 27 Apr 2026 01:07:46 +0700 Subject: [PATCH 08/10] refactor: migrate column layout and filter storage from UserDefaults to file-based --- CHANGELOG.md | 1 + .../Core/Storage/ColumnLayoutStorage.swift | 169 +++++++++++++-- .../Core/Storage/FilterSettingsStorage.swift | 196 ++++++++++-------- 3 files changed, 257 insertions(+), 109 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d81da1e96..bbbce0507 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 diff --git a/TablePro/Core/Storage/ColumnLayoutStorage.swift b/TablePro/Core/Storage/ColumnLayoutStorage.swift index b6aa5ae24..a0c3c6ab0 100644 --- a/TablePro/Core/Storage/ColumnLayoutStorage.swift +++ b/TablePro/Core/Storage/ColumnLayoutStorage.swift @@ -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 } @@ -27,19 +47,17 @@ 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 @@ -47,13 +65,126 @@ internal final class ColumnLayoutStorage { } 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[..? - - private var trackedKeys: Set { - get { - if let cached = _trackedKeys { return cached } - let array = defaults.stringArray(forKey: knownFilterKeysKey) ?? [] - let keys = Set(array) - _trackedKeys = keys - return keys - } - set { - _trackedKeys = newValue - defaults.set(Array(newValue), forKey: knownFilterKeysKey) - } - } + private init() { + filterStateDirectory = Self.resolvedFilterStateDirectory() - private let encoder = JSONEncoder() - private let decoder = JSONDecoder() - - private init() {} + do { + try FileManager.default.createDirectory( + at: filterStateDirectory, + withIntermediateDirectories: true + ) + } catch { + Self.logger.error("Failed to create filter state directory: \(error.localizedDescription)") + } - // MARK: - Settings + Self.performMigrationIfNeeded(filterStateDirectory: filterStateDirectory) + } - /// Load filter settings (cached after first read) func loadSettings() -> FilterSettings { if let cached = cachedSettings { return cached } @@ -145,7 +129,6 @@ final class FilterSettingsStorage { } } - /// Save filter settings func saveSettings(_ settings: FilterSettings) { cachedSettings = settings do { @@ -156,92 +139,125 @@ final class FilterSettingsStorage { } } - // MARK: - Per-Table Last Filters - - /// Load last-used filters for a specific table func loadLastFilters(for tableName: String) -> [TableFilter] { - let key = lastFiltersKeyPrefix + sanitizeTableName(tableName) - - if let cached = lastFiltersCache[key] { - return cached - } + let sanitized = sanitizeTableName(tableName) + if let cached = lastFiltersCache[sanitized] { return cached } - guard let data = defaults.data(forKey: key) else { + let fileURL = fileURL(forSanitizedName: sanitized) + guard FileManager.default.fileExists(atPath: fileURL.path) else { + lastFiltersCache[sanitized] = [] return [] } do { + let data = try Data(contentsOf: fileURL) let filters = try decoder.decode([TableFilter].self, from: data) - lastFiltersCache[key] = filters + lastFiltersCache[sanitized] = filters return filters } catch { - Self.logger.error("Failed to decode last filters for \(tableName): \(error)") + Self.logger.error("Failed to load last filters for \(tableName): \(error)") + lastFiltersCache[sanitized] = [] return [] } } - /// Save last-used filters for a specific table func saveLastFilters(_ filters: [TableFilter], for tableName: String) { - let key = lastFiltersKeyPrefix + sanitizeTableName(tableName) + let sanitized = sanitizeTableName(tableName) + let fileURL = fileURL(forSanitizedName: sanitized) - // Only save non-empty filter configurations guard !filters.isEmpty else { - defaults.removeObject(forKey: key) - removeTrackedKey(key) - lastFiltersCache.removeValue(forKey: key) + removeFile(at: fileURL, label: tableName) + lastFiltersCache.removeValue(forKey: sanitized) return } do { let data = try encoder.encode(filters) - defaults.set(data, forKey: key) - trackKey(key) - lastFiltersCache[key] = filters + try data.write(to: fileURL, options: .atomic) + lastFiltersCache[sanitized] = filters } catch { - Self.logger.error("Failed to encode last filters for \(tableName): \(error)") + Self.logger.error("Failed to save last filters for \(tableName): \(error)") } } - /// Clear last filters for a specific table func clearLastFilters(for tableName: String) { - let key = lastFiltersKeyPrefix + sanitizeTableName(tableName) - defaults.removeObject(forKey: key) - removeTrackedKey(key) - lastFiltersCache.removeValue(forKey: key) + let sanitized = sanitizeTableName(tableName) + let fileURL = fileURL(forSanitizedName: sanitized) + removeFile(at: fileURL, label: tableName) + lastFiltersCache.removeValue(forKey: sanitized) } - /// Clear all stored last filters using the tracked key set instead of - /// loading the full UserDefaults plist via `dictionaryRepresentation()`. func clearAllLastFilters() { - for key in trackedKeys { - defaults.removeObject(forKey: key) + let fm = FileManager.default + do { + let files = try fm.contentsOfDirectory(at: filterStateDirectory, includingPropertiesForKeys: nil) + for file in files where file.pathExtension == "json" { + try? fm.removeItem(at: file) + } + } catch { + Self.logger.error("Failed to enumerate filter state directory: \(error.localizedDescription)") } - _trackedKeys = nil - defaults.removeObject(forKey: knownFilterKeysKey) + lastFiltersCache.removeAll() } - // MARK: - Key Tracking - - /// Add a key to the tracked set. - private func trackKey(_ key: String) { - var keys = trackedKeys - if keys.insert(key).inserted { - trackedKeys = keys - } + private func fileURL(forSanitizedName sanitized: String) -> URL { + filterStateDirectory.appendingPathComponent("\(sanitized).json") } - /// Remove a key from the tracked set. - private func removeTrackedKey(_ key: String) { - var keys = trackedKeys - if keys.remove(key) != nil { - trackedKeys = keys + private func removeFile(at fileURL: URL, label: String) { + guard FileManager.default.fileExists(atPath: fileURL.path) else { return } + do { + try FileManager.default.removeItem(at: fileURL) + } catch { + Self.logger.error("Failed to remove last filters file for \(label): \(error.localizedDescription)") } } - // MARK: - Helpers - - /// Sanitize table name for use as UserDefaults key private func sanitizeTableName(_ tableName: String) -> String { tableName.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? tableName } + + private static func resolvedFilterStateDirectory() -> URL { + let appSupport = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first ?? FileManager.default.temporaryDirectory + return appSupport + .appendingPathComponent("TablePro", isDirectory: true) + .appendingPathComponent("FilterState", isDirectory: true) + } + + private static func performMigrationIfNeeded(filterStateDirectory: URL) { + let defaults = UserDefaults.standard + guard !defaults.bool(forKey: migrationCompleteKey) else { return } + + let allKeys = defaults.dictionaryRepresentation().keys + let legacyKeys = allKeys.filter { $0.hasPrefix(legacyLastFiltersKeyPrefix) } + + var migrated = 0 + for key in legacyKeys { + let sanitized = String(key.dropFirst(legacyLastFiltersKeyPrefix.count)) + guard !sanitized.isEmpty, + let data = defaults.data(forKey: key) else { + defaults.removeObject(forKey: key) + continue + } + + let fileURL = filterStateDirectory.appendingPathComponent("\(sanitized).json") + do { + try data.write(to: fileURL, options: .atomic) + migrated += 1 + } catch { + logger.error("Failed to migrate last filters for \(sanitized): \(error.localizedDescription)") + } + defaults.removeObject(forKey: key) + } + + defaults.removeObject(forKey: legacyKnownFilterKeysKey) + defaults.set(true, forKey: migrationCompleteKey) + + if migrated > 0 { + logger.trace("Migrated \(migrated) per-table filter entries to file storage") + } + } } From 8e222375c3deacb9d1dc3eaed117b2c135670363 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 27 Apr 2026 01:09:29 +0700 Subject: [PATCH 09/10] fix: re-apply localization to filter settings display names after storage migration --- TablePro/Core/Storage/FilterSettingsStorage.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/TablePro/Core/Storage/FilterSettingsStorage.swift b/TablePro/Core/Storage/FilterSettingsStorage.swift index 7399c50df..513c1233c 100644 --- a/TablePro/Core/Storage/FilterSettingsStorage.swift +++ b/TablePro/Core/Storage/FilterSettingsStorage.swift @@ -16,8 +16,8 @@ enum FilterDefaultColumn: String, CaseIterable, Identifiable, Codable { var displayName: String { switch self { case .rawSQL: return "Raw SQL" - case .primaryKey: return "Primary Key" - case .anyColumn: return "Any Column" + case .primaryKey: return String(localized: "Primary Key") + case .anyColumn: return String(localized: "Any Column") } } } @@ -51,9 +51,9 @@ enum FilterPanelDefaultState: String, CaseIterable, Identifiable, Codable { var displayName: String { switch self { - case .restoreLast: return "Restore Last Filter" - case .alwaysShow: return "Always Show" - case .alwaysHide: return "Always Hide" + case .restoreLast: return String(localized: "Restore Last Filter") + case .alwaysShow: return String(localized: "Always Show") + case .alwaysHide: return String(localized: "Always Hide") } } } From beb1a4636b84cf848aff1d864956cfbb5032e96f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 27 Apr 2026 01:20:20 +0700 Subject: [PATCH 10/10] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20rem?= =?UTF-8?q?ove=20URLSession!,=20add=20search=20field=20auto-focus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TablePro/MCPBridge/MCPBridgeProxy.swift | 13 +++++++------ .../DatabaseSwitcher/DatabaseSwitcherSheet.swift | 3 ++- .../Views/QuickSwitcher/QuickSwitcherView.swift | 3 ++- TablePro/Views/Sidebar/NativeSearchField.swift | 6 ++++++ 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/TablePro/MCPBridge/MCPBridgeProxy.swift b/TablePro/MCPBridge/MCPBridgeProxy.swift index 2cbfec99e..a74bde5ad 100644 --- a/TablePro/MCPBridge/MCPBridgeProxy.swift +++ b/TablePro/MCPBridge/MCPBridgeProxy.swift @@ -51,7 +51,6 @@ private final class CertificatePinningDelegate: NSObject, URLSessionDelegate { final class MCPBridgeProxy { private let handshakePath: String private var sessionId: String? - private var urlSession: URLSession! init() { let home = FileManager.default.homeDirectoryForCurrentUser.path @@ -93,13 +92,13 @@ final class MCPBridgeProxy { } else { delegate = nil } - self.urlSession = URLSession(configuration: config, delegate: delegate, delegateQueue: nil) + let urlSession = URLSession(configuration: config, delegate: delegate, delegateQueue: nil) let scheme = (handshake.tls ?? false) ? "https" : "http" let baseUrl = "\(scheme)://127.0.0.1:\(handshake.port)/mcp" let bearerToken = handshake.token - await readLoop(baseUrl: baseUrl, bearerToken: bearerToken) + await readLoop(baseUrl: baseUrl, bearerToken: bearerToken, urlSession: urlSession) } private func loadHandshake() throws -> MCPHandshake { @@ -111,7 +110,7 @@ final class MCPBridgeProxy { kill(pid, 0) == 0 } - private func readLoop(baseUrl: String, bearerToken: String) async { + private func readLoop(baseUrl: String, bearerToken: String, urlSession: URLSession) async { let stdin = FileHandle.standardInput var buffer = Data() @@ -136,7 +135,8 @@ final class MCPBridgeProxy { let responseData = try await forwardRequest( lineDataCopy, baseUrl: baseUrl, - bearerToken: bearerToken + bearerToken: bearerToken, + urlSession: urlSession ) writeStdout(responseData) writeStdout(Data([0x0A])) @@ -155,7 +155,8 @@ final class MCPBridgeProxy { private func forwardRequest( _ body: Data, baseUrl: String, - bearerToken: String + bearerToken: String, + urlSession: URLSession ) async throws -> Data { guard let url = URL(string: baseUrl) else { throw BridgeError.invalidUrl diff --git a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift index 4dbd0e1f8..14a4f8f5b 100644 --- a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift +++ b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift @@ -157,7 +157,8 @@ struct DatabaseSwitcherSheet: View { ? String(localized: "Search schemas...") : String(localized: "Search databases..."), onMoveUp: { viewModel.moveUp() }, - onMoveDown: { viewModel.moveDown() } + onMoveDown: { viewModel.moveDown() }, + focusOnAppear: true ) // Refresh diff --git a/TablePro/Views/QuickSwitcher/QuickSwitcherView.swift b/TablePro/Views/QuickSwitcher/QuickSwitcherView.swift index 286786768..5d987b834 100644 --- a/TablePro/Views/QuickSwitcher/QuickSwitcherView.swift +++ b/TablePro/Views/QuickSwitcher/QuickSwitcherView.swift @@ -89,7 +89,8 @@ internal struct QuickSwitcherSheet: View { text: $viewModel.searchText, placeholder: String(localized: "Search tables, views, databases..."), onMoveUp: { viewModel.moveUp() }, - onMoveDown: { viewModel.moveDown() } + onMoveDown: { viewModel.moveDown() }, + focusOnAppear: true ) .padding(.horizontal, 12) .padding(.vertical, 8) diff --git a/TablePro/Views/Sidebar/NativeSearchField.swift b/TablePro/Views/Sidebar/NativeSearchField.swift index bc10a0a85..2bd196544 100644 --- a/TablePro/Views/Sidebar/NativeSearchField.swift +++ b/TablePro/Views/Sidebar/NativeSearchField.swift @@ -14,6 +14,7 @@ struct NativeSearchField: NSViewRepresentable { var controlSize: NSControl.ControlSize = .regular var onMoveUp: (() -> Void)? var onMoveDown: (() -> Void)? + var focusOnAppear: Bool = false func makeNSView(context: Context) -> NSSearchField { let field = NSSearchField() @@ -22,6 +23,11 @@ struct NativeSearchField: NSViewRepresentable { field.controlSize = controlSize field.sendsSearchStringImmediately = true field.setAccessibilityIdentifier("sidebar-filter") + if focusOnAppear { + DispatchQueue.main.async { + field.window?.makeFirstResponder(field) + } + } return field }