diff --git a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift index 2bc91570b..c30e91f18 100644 --- a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift +++ b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift @@ -440,6 +440,18 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable { var supportsSchemas: Bool { false } var supportsTransactions: Bool { true } + var capabilities: PluginCapabilities { + [ + .parameterizedQueries, + .transactions, + .alterTableDDL, + .foreignKeyToggle, + .truncateTable, + .cancelQuery, + .batchExecute, + ] + } + func quoteIdentifier(_ name: String) -> String { let escaped = name.replacingOccurrences(of: "`", with: "``") return "`\(escaped)`" diff --git a/Plugins/TableProPluginKit/PluginCapabilities.swift b/Plugins/TableProPluginKit/PluginCapabilities.swift new file mode 100644 index 000000000..bb9d18d8a --- /dev/null +++ b/Plugins/TableProPluginKit/PluginCapabilities.swift @@ -0,0 +1,22 @@ +import Foundation + +public struct PluginCapabilities: OptionSet, Sendable { + public let rawValue: UInt32 + + public init(rawValue: UInt32) { + self.rawValue = rawValue + } + + public static let materializedViews = PluginCapabilities(rawValue: 1 << 0) + public static let foreignTables = PluginCapabilities(rawValue: 1 << 1) + public static let storedProcedures = PluginCapabilities(rawValue: 1 << 2) + public static let userFunctions = PluginCapabilities(rawValue: 1 << 3) + public static let alterTableDDL = PluginCapabilities(rawValue: 1 << 4) + public static let foreignKeyToggle = PluginCapabilities(rawValue: 1 << 5) + public static let truncateTable = PluginCapabilities(rawValue: 1 << 6) + public static let multiSchema = PluginCapabilities(rawValue: 1 << 7) + public static let parameterizedQueries = PluginCapabilities(rawValue: 1 << 8) + public static let cancelQuery = PluginCapabilities(rawValue: 1 << 9) + public static let batchExecute = PluginCapabilities(rawValue: 1 << 10) + public static let transactions = PluginCapabilities(rawValue: 1 << 11) +} diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index 4fd8d3a3c..a4676d25a 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -31,6 +31,8 @@ public struct PluginRowChange: Sendable { } public protocol PluginDatabaseDriver: AnyObject, Sendable { + var capabilities: PluginCapabilities { get } + // Connection func connect() async throws func disconnect() @@ -141,6 +143,8 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { } public extension PluginDatabaseDriver { + var capabilities: PluginCapabilities { [] } + var supportsSchemas: Bool { false } func fetchSchemas() async throws -> [String] { [] } diff --git a/Plugins/TableProPluginKit/PluginProcedureFunctionSupport.swift b/Plugins/TableProPluginKit/PluginProcedureFunctionSupport.swift new file mode 100644 index 000000000..3cc8807be --- /dev/null +++ b/Plugins/TableProPluginKit/PluginProcedureFunctionSupport.swift @@ -0,0 +1,20 @@ +import Foundation + +public protocol PluginProcedureFunctionSupport { + func fetchProcedures(schema: String?) async throws -> [PluginRoutineInfo] + func fetchFunctions(schema: String?) async throws -> [PluginRoutineInfo] + func fetchProcedureDDL(name: String, schema: String?) async throws -> String + func fetchFunctionDDL(name: String, schema: String?) async throws -> String +} + +public struct PluginRoutineInfo: Codable, Sendable { + public let name: String + public let returnType: String? + public let language: String? + + public init(name: String, returnType: String? = nil, language: String? = nil) { + self.name = name + self.returnType = returnType + self.language = language + } +} diff --git a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift index d825656fa..8b1cdf3d3 100644 --- a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift +++ b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift @@ -6,6 +6,17 @@ // import Foundation +import os + +private let regexLogger = Logger(subsystem: "com.TablePro", category: "SQLContextAnalyzer.Regex") + +private func compileRegex(_ pattern: String, options: NSRegularExpression.Options = []) -> NSRegularExpression { + if let regex = try? NSRegularExpression(pattern: pattern, options: options) { + return regex + } + regexLogger.fault("Failed to compile static regex pattern: \(pattern, privacy: .public)") + return NSRegularExpression() +} /// Type of SQL clause the cursor is in enum SQLClauseType { @@ -188,80 +199,28 @@ final class SQLContextAnalyzer { // SELECT is most general ("\\bSELECT\\s+[^;]*$", .select), ] - return patterns.compactMap { pattern, clause in - guard let regex = try? NSRegularExpression( - pattern: pattern, options: .caseInsensitive - ) else { - assertionFailure("Invalid SQL clause regex pattern: \(pattern)") - return nil - } - return (regex, clause) + return patterns.map { pattern, clause in + (compileRegex(pattern, options: .caseInsensitive), clause) } }() - /// Pre-compiled regex for removing strings and comments - private static let singleQuoteStringRegex: NSRegularExpression = { - if let regex = try? NSRegularExpression(pattern: "'[^']*'") { - return regex - } - assertionFailure("Failed to compile singleQuoteStringRegex - invalid pattern") - return try! NSRegularExpression(pattern: "(?!)") - }() + private static let singleQuoteStringRegex = compileRegex("'[^']*'") - private static let doubleQuoteStringRegex: NSRegularExpression = { - if let regex = try? NSRegularExpression(pattern: "\"[^\"]*\"") { - return regex - } - assertionFailure("Failed to compile doubleQuoteStringRegex - invalid pattern") - return try! NSRegularExpression(pattern: "(?!)") - }() + private static let doubleQuoteStringRegex = compileRegex("\"[^\"]*\"") - private static let blockCommentRegex: NSRegularExpression = { - if let regex = try? NSRegularExpression(pattern: "/\\*[\\s\\S]*?\\*/") { - return regex - } - assertionFailure("Failed to compile blockCommentRegex - invalid pattern") - return try! NSRegularExpression(pattern: "(?!)") - }() + private static let blockCommentRegex = compileRegex("/\\*[\\s\\S]*?\\*/") - private static let lineCommentRegex: NSRegularExpression = { - if let regex = try? NSRegularExpression(pattern: "--[^\n]*") { - return regex - } - assertionFailure("Failed to compile lineCommentRegex - invalid pattern") - return try! NSRegularExpression(pattern: "(?!)") - }() + private static let lineCommentRegex = compileRegex("--[^\n]*") - /// Combined regex for removing strings and comments in a single pass (SVC-13) - private static let stringsAndCommentsRegex: NSRegularExpression = { - // Alternation: single-quoted strings | double-quoted strings | block comments | line comments - let pattern = #"'[^']*'|"[^"]*"|/\*[\s\S]*?\*/|--[^\n]*"# - if let regex = try? NSRegularExpression(pattern: pattern) { - return regex - } - assertionFailure("Failed to compile stringsAndCommentsRegex - invalid pattern") - return try! NSRegularExpression(pattern: "(?!)") - }() + private static let stringsAndCommentsRegex = compileRegex( + #"'[^']*'|"[^"]*"|/\*[\s\S]*?\*/|--[^\n]*"# + ) - private static let cteFirstRegex: NSRegularExpression = { - if let regex = try? NSRegularExpression( - pattern: "(?i)\\bWITH\\s+(?:RECURSIVE\\s+)?([\\w]+)\\s+AS\\s*\\(" - ) { - return regex - } - assertionFailure("Failed to compile cteFirstRegex") - return try! NSRegularExpression(pattern: "(?!)") - }() + private static let cteFirstRegex = compileRegex( + "(?i)\\bWITH\\s+(?:RECURSIVE\\s+)?([\\w]+)\\s+AS\\s*\\(" + ) - private static let cteCommaRegex: NSRegularExpression = { - if let regex = try? NSRegularExpression( - pattern: "(?i),\\s*([\\w]+)\\s+AS\\s*\\(" - ) { - return regex - } - assertionFailure("Failed to compile cteCommaRegex") - return try! NSRegularExpression(pattern: "(?!)") - }() + private static let cteCommaRegex = compileRegex("(?i),\\s*([\\w]+)\\s+AS\\s*\\(") private static let tableRefRegexes: [NSRegularExpression] = { let patterns = [ @@ -274,7 +233,7 @@ final class SQLContextAnalyzer { "(?i)\\bINSERT\\s+INTO\\s+[`\"']?([\\w.]+)[`\"']?", "(?i)\\bCREATE\\s+(?:UNIQUE\\s+)?INDEX\\s+\\w+\\s+ON\\s+[`\"']?([\\w.]+)[`\"']?" ] - return patterns.compactMap { try? NSRegularExpression(pattern: $0) } + return patterns.map { compileRegex($0) } }() // MARK: - UTF-16 Helpers diff --git a/TablePro/Core/Database/DatabaseManager+Sessions.swift b/TablePro/Core/Database/DatabaseManager+Sessions.swift index 1a9ef4acd..23403c4ab 100644 --- a/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -6,6 +6,7 @@ // import AppKit +import Combine import Foundation import os import TableProPluginKit @@ -378,13 +379,17 @@ extension DatabaseManager { internal func setSession(_ session: ConnectionSession, for connectionId: UUID) { activeSessions[connectionId] = session connectionStatusVersions[connectionId, default: 0] &+= 1 - NotificationCenter.default.post(name: .connectionStatusDidChange, object: connectionId) + AppEvents.shared.connectionStatusChanged.send( + ConnectionStatusChange(connectionId: connectionId, status: session.status) + ) } internal func removeSessionEntry(for connectionId: UUID) { activeSessions.removeValue(forKey: connectionId) connectionStatusVersions.removeValue(forKey: connectionId) - NotificationCenter.default.post(name: .connectionStatusDidChange, object: connectionId) + AppEvents.shared.connectionStatusChanged.send( + ConnectionStatusChange(connectionId: connectionId, status: .disconnected) + ) } #if DEBUG diff --git a/TablePro/Core/Events/AppEvents.swift b/TablePro/Core/Events/AppEvents.swift new file mode 100644 index 000000000..d451aca23 --- /dev/null +++ b/TablePro/Core/Events/AppEvents.swift @@ -0,0 +1,23 @@ +// +// AppEvents.swift +// TablePro +// + +import Combine +import Foundation + +@MainActor +final class AppEvents { + static let shared = AppEvents() + + let themeChanged = PassthroughSubject() + + let connectionStatusChanged = PassthroughSubject() + + private init() {} +} + +struct ConnectionStatusChange: Sendable { + let connectionId: UUID + let status: ConnectionStatus +} diff --git a/TablePro/Core/Services/AppServices.swift b/TablePro/Core/Services/AppServices.swift new file mode 100644 index 000000000..1007ddd1b --- /dev/null +++ b/TablePro/Core/Services/AppServices.swift @@ -0,0 +1,46 @@ +// +// AppServices.swift +// TablePro +// + +import SwiftUI + +@MainActor +struct AppServices { + let appEvents: AppEvents + let appSettings: AppSettingsManager + let connectionStorage: ConnectionStorage + let databaseManager: DatabaseManager + let pluginManager: PluginManager + let schemaService: SchemaService + let queryHistoryStorage: QueryHistoryStorage + let sqlFavoriteManager: SQLFavoriteManager + let aiChatStorage: AIChatStorage + let syncTracker: SyncChangeTracker + let themeEngine: ThemeEngine + + static let live = AppServices( + appEvents: .shared, + appSettings: .shared, + connectionStorage: .shared, + databaseManager: .shared, + pluginManager: .shared, + schemaService: .shared, + queryHistoryStorage: .shared, + sqlFavoriteManager: .shared, + aiChatStorage: .shared, + syncTracker: .shared, + themeEngine: .shared + ) +} + +private struct AppServicesEnvironmentKey: EnvironmentKey { + @MainActor static var defaultValue: AppServices { .live } +} + +extension EnvironmentValues { + var appServices: AppServices { + get { self[AppServicesEnvironmentKey.self] } + set { self[AppServicesEnvironmentKey.self] = newValue } + } +} diff --git a/TablePro/Core/Services/Infrastructure/AppNotifications.swift b/TablePro/Core/Services/Infrastructure/AppNotifications.swift index 13937ba50..510054529 100644 --- a/TablePro/Core/Services/Infrastructure/AppNotifications.swift +++ b/TablePro/Core/Services/Infrastructure/AppNotifications.swift @@ -17,7 +17,6 @@ extension Notification.Name { // MARK: - Connections static let connectionUpdated = Notification.Name("connectionUpdated") - static let connectionStatusDidChange = Notification.Name("connectionStatusDidChange") static let databaseDidConnect = Notification.Name("databaseDidConnect") static let exportConnections = Notification.Name("exportConnections") static let importConnections = Notification.Name("importConnections") diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index f2e0704a2..4e382f2ee 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -9,6 +9,7 @@ // import AppKit +import Combine import os import SwiftUI @@ -45,7 +46,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi // MARK: - Observers - private var connectionStatusObserver: NSObjectProtocol? + private var connectionStatusCancellable: AnyCancellable? // MARK: - Init @@ -196,24 +197,17 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi // MARK: - Observers private func installObservers() { - guard connectionStatusObserver == nil else { return } - connectionStatusObserver = NotificationCenter.default.addObserver( - forName: .connectionStatusDidChange, - object: nil, - queue: .main - ) { [weak self] _ in - MainActor.assumeIsolated { + guard connectionStatusCancellable == nil else { return } + connectionStatusCancellable = AppEvents.shared.connectionStatusChanged + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.handleConnectionStatusChange() } - } handleConnectionStatusChange() } private func removeObservers() { - if let observer = connectionStatusObserver { - NotificationCenter.default.removeObserver(observer) - connectionStatusObserver = nil - } + connectionStatusCancellable = nil } // MARK: - Toolbar diff --git a/TablePro/Core/Services/Infrastructure/SettingsNotifications.swift b/TablePro/Core/Services/Infrastructure/SettingsNotifications.swift index ae67f08b9..f3e0d98b3 100644 --- a/TablePro/Core/Services/Infrastructure/SettingsNotifications.swift +++ b/TablePro/Core/Services/Infrastructure/SettingsNotifications.swift @@ -21,10 +21,6 @@ extension Notification.Name { /// Observers should reload fonts via ThemeEngine.shared.reloadFontCaches(). static let accessibilityTextSizeDidChange = Notification.Name("accessibilityTextSizeDidChange") - /// Posted when the active theme changes (colors, fonts, or entire theme switch). - /// Used by AppKit components that cannot observe @Observable directly. - static let themeDidChange = Notification.Name("themeDidChange") - /// Posted when terminal settings change (font, theme, cursor, etc.) /// Used by terminal views to live-update configuration. static let terminalSettingsDidChange = Notification.Name("terminalSettingsDidChange") diff --git a/TablePro/Core/Storage/SSHProfileStorage.swift b/TablePro/Core/Storage/SSHProfileStorage.swift index a1a3baa5c..f438ebcdd 100644 --- a/TablePro/Core/Storage/SSHProfileStorage.swift +++ b/TablePro/Core/Storage/SSHProfileStorage.swift @@ -78,11 +78,11 @@ final class SSHProfileStorage { } func deleteProfile(_ profile: SSHProfile) { - SyncChangeTracker.shared.markDeleted(.sshProfile, id: profile.id.uuidString) var profiles = loadProfiles() guard !lastLoadFailed else { return } profiles.removeAll { $0.id == profile.id } saveProfiles(profiles) + SyncChangeTracker.shared.markDeleted(.sshProfile, id: profile.id.uuidString) deleteSSHPassword(for: profile.id) deleteKeyPassphrase(for: profile.id) diff --git a/TablePro/Core/Storage/TagStorage.swift b/TablePro/Core/Storage/TagStorage.swift index b461fa11a..a19003069 100644 --- a/TablePro/Core/Storage/TagStorage.swift +++ b/TablePro/Core/Storage/TagStorage.swift @@ -77,10 +77,10 @@ final class TagStorage { /// Delete a custom tag (presets cannot be deleted) func deleteTag(_ tag: ConnectionTag) { guard !tag.isPreset else { return } - SyncChangeTracker.shared.markDeleted(.tag, id: tag.id.uuidString) var tags = loadTags() tags.removeAll { $0.id == tag.id } saveTags(tags) + SyncChangeTracker.shared.markDeleted(.tag, id: tag.id.uuidString) } /// Get tag by ID diff --git a/TablePro/Core/Utilities/Connection/EnvVarResolver.swift b/TablePro/Core/Utilities/Connection/EnvVarResolver.swift index 3378ed193..00a43826b 100644 --- a/TablePro/Core/Utilities/Connection/EnvVarResolver.swift +++ b/TablePro/Core/Utilities/Connection/EnvVarResolver.swift @@ -11,11 +11,14 @@ import os internal enum EnvVarResolver { private static let logger = Logger(subsystem: "com.TablePro", category: "EnvVarResolver") - // Matches ${VAR_NAME} or $VAR_NAME - // swiftlint:disable:next force_try - private static let pattern = try! NSRegularExpression( - pattern: #"\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)"# - ) + private static let pattern: NSRegularExpression = { + let source = #"\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)"# + if let regex = try? NSRegularExpression(pattern: source) { + return regex + } + logger.fault("Failed to compile EnvVarResolver pattern: \(source, privacy: .public)") + return NSRegularExpression() + }() /// Resolve environment variable references in a string. /// Unresolved variables are left as-is and logged as warnings. diff --git a/TablePro/Models/Query/QueryResult.swift b/TablePro/Models/Query/QueryResult.swift index 92434a0fc..7a71aea86 100644 --- a/TablePro/Models/Query/QueryResult.swift +++ b/TablePro/Models/Query/QueryResult.swift @@ -169,7 +169,7 @@ struct ForeignKeyInfo: Identifiable, Hashable { } /// Connection status -enum ConnectionStatus: Equatable { +enum ConnectionStatus: Equatable, Sendable { case disconnected case connecting case connected diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index 6e08c8730..e2366ab1a 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -644,6 +644,7 @@ struct TableProApp: App { hideMiniaturizeButton: true, hideZoomButton: true )) + .environment(\.appServices, .live) } .windowResizability(.contentSize) .windowStyle(.hiddenTitleBar) @@ -652,6 +653,7 @@ struct TableProApp: App { WindowGroup("New Connection", id: SceneId.connectionForm, for: UUID?.self) { $editingId in ConnectionFormView(connectionId: editingId ?? nil) .background(WindowChromeConfigurator(restorable: false)) + .environment(\.appServices, .live) } .windowResizability(.contentMinSize) .defaultSize(width: 820, height: 600) @@ -659,6 +661,7 @@ struct TableProApp: App { Window("Integrations Activity", id: SceneId.integrationsActivity) { IntegrationsActivityView() + .environment(\.appServices, .live) } .windowResizability(.contentMinSize) .defaultSize(width: 960, height: 600) @@ -673,6 +676,7 @@ struct TableProApp: App { Settings { SettingsView() .environment(updaterBridge) + .environment(\.appServices, .live) } } } diff --git a/TablePro/Theme/ThemeEngine.swift b/TablePro/Theme/ThemeEngine.swift index d9b03ea49..2add9bf55 100644 --- a/TablePro/Theme/ThemeEngine.swift +++ b/TablePro/Theme/ThemeEngine.swift @@ -8,6 +8,7 @@ import AppKit import CodeEditSourceEditor +import Combine import Foundation import Observation import os @@ -371,7 +372,7 @@ internal final class ThemeEngine { // MARK: - Notifications private func notifyThemeDidChange() { - NotificationCenter.default.post(name: .themeDidChange, object: self) + AppEvents.shared.themeChanged.send(()) } // MARK: - Accessibility diff --git a/TablePro/ViewModels/QuickSwitcherViewModel.swift b/TablePro/ViewModels/QuickSwitcherViewModel.swift index 2714d954f..dd6784d6c 100644 --- a/TablePro/ViewModels/QuickSwitcherViewModel.swift +++ b/TablePro/ViewModels/QuickSwitcherViewModel.swift @@ -14,6 +14,8 @@ import os internal final class QuickSwitcherViewModel { private static let logger = Logger(subsystem: "com.TablePro", category: "QuickSwitcherViewModel") + @ObservationIgnored private let services: AppServices + // MARK: - State var searchText = "" { @@ -33,6 +35,10 @@ internal final class QuickSwitcherViewModel { /// Maximum number of results to display private let maxResults = 100 + init(services: AppServices = .live) { + self.services = services + } + // MARK: - Loading /// Load all searchable items from the database schema, databases, schemas, and history @@ -71,7 +77,7 @@ internal final class QuickSwitcherViewModel { } // Databases - if let driver = DatabaseManager.shared.driver(for: connectionId) { + if let driver = services.databaseManager.driver(for: connectionId) { do { let databases = try await driver.fetchDatabases() for db in databases { @@ -86,7 +92,7 @@ internal final class QuickSwitcherViewModel { Self.logger.warning("Failed to fetch databases for quick switcher: \(error.localizedDescription, privacy: .public)") } - if PluginManager.shared.supportsSchemaSwitching(for: databaseType) { + if services.pluginManager.supportsSchemaSwitching(for: databaseType) { do { let schemas = try await driver.fetchSchemas() for schema in schemas { @@ -104,7 +110,7 @@ internal final class QuickSwitcherViewModel { } // Recent query history (last 50) - let historyEntries = await QueryHistoryStorage.shared.fetchHistory( + let historyEntries = await services.queryHistoryStorage.fetchHistory( limit: 50, connectionId: connectionId ) diff --git a/TablePro/Views/AIChat/AIChatContextChipView.swift b/TablePro/Views/AIChat/AIChatContextChipView.swift index 9c76d6079..7314a3410 100644 --- a/TablePro/Views/AIChat/AIChatContextChipView.swift +++ b/TablePro/Views/AIChat/AIChatContextChipView.swift @@ -21,7 +21,7 @@ struct AIChatContextChipView: View { if let onRemove { Button(action: onRemove) { Image(systemName: "xmark") - .font(.system(size: 9, weight: .semibold)) + .font(.caption2.weight(.semibold)) } .buttonStyle(.plain) .foregroundStyle(.secondary) diff --git a/TablePro/Views/AIChat/ChatComposerView.swift b/TablePro/Views/AIChat/ChatComposerView.swift index f06568f57..ddc6795c5 100644 --- a/TablePro/Views/AIChat/ChatComposerView.swift +++ b/TablePro/Views/AIChat/ChatComposerView.swift @@ -58,9 +58,11 @@ struct ChatComposerView: View { if isFocused { IntelligenceFocusBorder(shape: shape) .transition(.opacity) + .accessibilityHidden(true) } else { shape.stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) .transition(.opacity) + .accessibilityHidden(true) } } .animation(.easeOut(duration: 0.25), value: isFocused) diff --git a/TablePro/Views/ConnectionForm/Toolbar/TestConnectionStatusButton.swift b/TablePro/Views/ConnectionForm/Toolbar/TestConnectionStatusButton.swift index 876ea49ba..febaa0d8e 100644 --- a/TablePro/Views/ConnectionForm/Toolbar/TestConnectionStatusButton.swift +++ b/TablePro/Views/ConnectionForm/Toolbar/TestConnectionStatusButton.swift @@ -22,6 +22,18 @@ struct TestConnectionStatusButton: View { .disabled(coordinator.isTesting || coordinator.isInstallingPlugin || !coordinator.isFormValid) + .help(helpText) + .accessibilityLabel(helpText) + } + + private var helpText: String { + if coordinator.isTesting { + return String(localized: "Testing connection") + } + if coordinator.testSucceeded { + return String(localized: "Connection succeeded") + } + return String(localized: "Test the current connection settings") } @ViewBuilder diff --git a/TablePro/Views/ERDiagram/ERDiagramNodeRenderer.swift b/TablePro/Views/ERDiagram/ERDiagramNodeRenderer.swift index cc51db8f4..04fd701e0 100644 --- a/TablePro/Views/ERDiagram/ERDiagramNodeRenderer.swift +++ b/TablePro/Views/ERDiagram/ERDiagramNodeRenderer.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI /// Renders table nodes imperatively on a Canvas GraphicsContext. @@ -10,6 +11,26 @@ enum ERDiagramNodeRenderer { private static let maxTableNameChars = 24 private static let maxTypeChars = 18 + private static var headerPointSize: CGFloat { + NSFont.preferredFont(forTextStyle: .caption1).pointSize + } + + private static var iconPointSize: CGFloat { + NSFont.preferredFont(forTextStyle: .caption2).pointSize + } + + private static var badgePointSize: CGFloat { + NSFont.preferredFont(forTextStyle: .caption2).pointSize * 0.75 + } + + private static var columnNamePointSize: CGFloat { + NSFont.preferredFont(forTextStyle: .caption1).pointSize * (11.0 / 12.0) + } + + private static var columnTypePointSize: CGFloat { + NSFont.preferredFont(forTextStyle: .caption2).pointSize + } + static func drawNode( context: inout GraphicsContext, node: ERTableNode, @@ -44,7 +65,7 @@ enum ERDiagramNodeRenderer { ? String(node.tableName.prefix(maxTableNameChars)) + "\u{2026}" : node.tableName let headerText = Text(displayName) - .font(.system(size: 12 * scale, weight: .semibold, design: .monospaced)) + .font(.system(size: Self.headerPointSize * scale, weight: .semibold, design: .monospaced)) context.draw( context.resolve(headerText), at: CGPoint(x: rect.minX + headerTextXOffset, y: rect.minY + headerHeight / 2), @@ -53,7 +74,7 @@ enum ERDiagramNodeRenderer { // Table icon let iconText = Text(Image(systemName: "tablecells")) - .font(.system(size: 10 * scale)) + .font(.system(size: Self.iconPointSize * scale)) .foregroundStyle(.secondary) context.draw( context.resolve(iconText), @@ -77,15 +98,15 @@ enum ERDiagramNodeRenderer { // PK/FK badge if col.isPrimaryKey { - let badge = Text(Image(systemName: "key.fill")).font(.system(size: 8 * scale)).foregroundStyle(Color(nsColor: .systemYellow)) + let badge = Text(Image(systemName: "key.fill")).font(.system(size: Self.badgePointSize * scale)).foregroundStyle(Color(nsColor: .systemYellow)) clipped.draw(clipped.resolve(badge), at: CGPoint(x: rect.minX + badgeXOffset, y: rowY), anchor: .center) } else if col.isForeignKey { - let badge = Text(Image(systemName: "link")).font(.system(size: 8 * scale)).foregroundStyle(Color(nsColor: .systemBlue)) + let badge = Text(Image(systemName: "link")).font(.system(size: Self.badgePointSize * scale)).foregroundStyle(Color(nsColor: .systemBlue)) clipped.draw(clipped.resolve(badge), at: CGPoint(x: rect.minX + badgeXOffset, y: rowY), anchor: .center) } // Column name - let nameText = Text(col.name).font(.system(size: 11 * scale, design: .monospaced)) + let nameText = Text(col.name).font(.system(size: Self.columnNamePointSize * scale, design: .monospaced)) clipped.draw( clipped.resolve(nameText), at: CGPoint(x: rect.minX + columnNameXOffset, y: rowY), @@ -97,7 +118,7 @@ enum ERDiagramNodeRenderer { ? String(col.dataType.prefix(maxTypeChars)) + "\u{2026}" : col.dataType let typeText = Text(displayType) - .font(.system(size: 10 * scale, design: .monospaced)) + .font(.system(size: Self.columnTypePointSize * scale, design: .monospaced)) .foregroundStyle(.secondary) clipped.draw( clipped.resolve(typeText), diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 19528bff2..1959a7ae9 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -322,8 +322,8 @@ struct MainContentView: View { } .task { handleConnectionStatusChange() } .onReceive( - NotificationCenter.default.publisher(for: .connectionStatusDidChange) - .filter { ($0.object as? UUID) == connection.id } + AppEvents.shared.connectionStatusChanged + .filter { $0.connectionId == connection.id } ) { _ in handleConnectionStatusChange() } diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 44bf26860..8e28f8a87 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -1,4 +1,5 @@ import AppKit +import Combine import SwiftUI // MARK: - Coordinator @@ -92,7 +93,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData var overlayEditor: CellOverlayEditor? var settingsObserver: NSObjectProtocol? - var themeObserver: NSObjectProtocol? + var themeCancellable: AnyCancellable? private var lastDataGridSettings: DataGridSettings @Binding var selectedRowIndices: Set @@ -179,16 +180,12 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData } func observeThemeChanges() { - themeObserver = NotificationCenter.default.addObserver( - forName: .themeDidChange, - object: nil, - queue: .main - ) { [weak self] _ in - Task { @MainActor [weak self] in + themeCancellable = AppEvents.shared.themeChanged + .receive(on: RunLoop.main) + .sink { [weak self] _ in guard let self, let tableView = self.tableView else { return } Self.updateVisibleCellFonts(tableView: tableView) } - } } func observeTeardown(connectionId: UUID) { @@ -230,9 +227,6 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData if let observer = settingsObserver { NotificationCenter.default.removeObserver(observer) } - if let observer = themeObserver { - NotificationCenter.default.removeObserver(observer) - } if let observer = teardownObserver { NotificationCenter.default.removeObserver(observer) } diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 2f9d04128..e016b8055 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -363,10 +363,7 @@ struct DataGridView: NSViewRepresentable { NotificationCenter.default.removeObserver(observer) coordinator.settingsObserver = nil } - if let observer = coordinator.themeObserver { - NotificationCenter.default.removeObserver(observer) - coordinator.themeObserver = nil - } + coordinator.themeCancellable = nil coordinator.tableRowsController.detach() } diff --git a/TablePro/Views/Results/JSONHighlightPatterns.swift b/TablePro/Views/Results/JSONHighlightPatterns.swift index 2997daaa7..4b7f8471e 100644 --- a/TablePro/Views/Results/JSONHighlightPatterns.swift +++ b/TablePro/Views/Results/JSONHighlightPatterns.swift @@ -3,13 +3,21 @@ // TablePro import Foundation +import os + +private let patternLogger = Logger(subsystem: "com.TablePro", category: "JSONHighlightPatterns") + +private func compileJSONRegex(_ pattern: String) -> NSRegularExpression { + if let regex = try? NSRegularExpression(pattern: pattern) { + return regex + } + patternLogger.fault("Failed to compile JSON highlight pattern: \(pattern, privacy: .public)") + return NSRegularExpression() +} -// Patterns are compile-time string literals — NSRegularExpression init cannot fail. -// swiftlint:disable force_try internal enum JSONHighlightPatterns { - static let string = try! NSRegularExpression(pattern: "\"(?:[^\"\\\\]|\\\\.)*\"") - static let key = try! NSRegularExpression(pattern: "(\"(?:[^\"\\\\]|\\\\.)*\")\\s*:") - static let number = try! NSRegularExpression(pattern: "(?<=[\\s,:\\[{])-?\\d+\\.?\\d*(?:[eE][+-]?\\d+)?(?=[\\s,\\]}])") - static let booleanNull = try! NSRegularExpression(pattern: "\\b(?:true|false|null)\\b") + static let string = compileJSONRegex("\"(?:[^\"\\\\]|\\\\.)*\"") + static let key = compileJSONRegex("(\"(?:[^\"\\\\]|\\\\.)*\")\\s*:") + static let number = compileJSONRegex("(?<=[\\s,:\\[{])-?\\d+\\.?\\d*(?:[eE][+-]?\\d+)?(?=[\\s,\\]}])") + static let booleanNull = compileJSONRegex("\\b(?:true|false|null)\\b") } -// swiftlint:enable force_try diff --git a/TablePro/Views/RightSidebar/UnifiedRightPanelView.swift b/TablePro/Views/RightSidebar/UnifiedRightPanelView.swift index 10897ccc3..e2ea61f0b 100644 --- a/TablePro/Views/RightSidebar/UnifiedRightPanelView.swift +++ b/TablePro/Views/RightSidebar/UnifiedRightPanelView.swift @@ -125,7 +125,7 @@ struct UnifiedRightPanelView: View { private func inspectorIcon(_ systemName: String) -> some View { Image(systemName: systemName) - .font(.system(size: 13, weight: .regular)) + .font(.subheadline) .symbolRenderingMode(.hierarchical) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/TablePro/Views/Settings/LicenseActivationSheet.swift b/TablePro/Views/Settings/LicenseActivationSheet.swift index 5c69a234d..8decc7d98 100644 --- a/TablePro/Views/Settings/LicenseActivationSheet.swift +++ b/TablePro/Views/Settings/LicenseActivationSheet.swift @@ -72,9 +72,7 @@ struct LicenseActivationSheet: View { Button("Cancel") { dismiss() } - .font(.subheadline) - .buttonStyle(.plain) - .foregroundStyle(.secondary) + .keyboardShortcut(.cancelAction) } } .padding(.top, 20) diff --git a/TablePro/Views/Sidebar/FileConflictDiffSheet.swift b/TablePro/Views/Sidebar/FileConflictDiffSheet.swift index 198f9e66e..2c3b9387a 100644 --- a/TablePro/Views/Sidebar/FileConflictDiffSheet.swift +++ b/TablePro/Views/Sidebar/FileConflictDiffSheet.swift @@ -29,7 +29,8 @@ internal struct FileConflictDiffSheet: View { Divider() footer } - .frame(width: 760, height: 540) + .frame(minWidth: 600, idealWidth: 760, maxWidth: .infinity, + minHeight: 400, idealHeight: 540, maxHeight: .infinity) } private var header: some View {