diff --git a/CHANGELOG.md b/CHANGELOG.md index ed15523614..4387f7a288 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,6 +87,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Oracle login timeout that never fired, leaving the connecting spinner up past its deadline. (#2919) - Crash from an Oracle server sending a marker packet, or an accept packet under 32 bytes, during login. (#2919) - Crash on launch when a pairing deep link opens the approval sheet. (#2930) +- `Cmd+Return` inserting the highlighted completion instead of running the query while the autocomplete list is open. - Wrong table dropped, truncated or opened from a PostgreSQL partition that lives in another schema. (#2523) - A PostgreSQL partition missing from the object list when its parent table is not readable. (#2523) - A PostgreSQL foreign-table partition listed twice, under Foreign Tables and under its parent. (#2523) diff --git a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoTestSupport.swift b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoTestSupport.swift index 7721d0c134..da5e1a0084 100644 --- a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoTestSupport.swift +++ b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoTestSupport.swift @@ -36,6 +36,14 @@ final class StubTransport: TrinoTransport, @unchecked Sendable { return recorded.count - 1 } onSend?(request, index) + /// A `DELETE` is the client releasing a statement it cancelled, fired from a detached task + /// with its response discarded. Served from the queue it raced the statement's own in-flight + /// `GET` for the next canned page: on CI it won, the `GET` got the empty default, read it + /// as the last page and returned, and `testCancelStopsPolling` failed with "Expected + /// cancellation" although the cancel had landed. + guard request.method != .delete else { + return TrinoHTTPResponse(statusCode: 204, headers: TrinoHeaderFields([:]), body: Data()) + } let canned = lock.withLock { () -> Canned in guard !queue.isEmpty else { return Canned(statusCode: 200, headers: [:], body: Data(#"{"id":"empty"}"#.utf8)) @@ -54,6 +62,14 @@ func canned(_ json: String, status: Int = 200, headers: [String: String] = [:]) StubTransport.Canned(statusCode: status, headers: headers, body: Data(json.utf8)) } +/// Hands the client to an `onSend` hook that runs on whatever thread the transport was resumed on, +/// so the store is locked: the write happens on the test's thread and the read inside `send`. final class ClientBox: @unchecked Sendable { - var client: TrinoStatementClient? + private let lock = NSLock() + private var stored: TrinoStatementClient? + + var client: TrinoStatementClient? { + get { lock.withLock { stored } } + set { lock.withLock { stored = newValue } } + } } diff --git a/Packages/TableProEditor/Sources/TableProEditorKit/CodeSuggestion/Window/SuggestionController.swift b/Packages/TableProEditor/Sources/TableProEditorKit/CodeSuggestion/Window/SuggestionController.swift index 50527c4110..d8798763b4 100644 --- a/Packages/TableProEditor/Sources/TableProEditorKit/CodeSuggestion/Window/SuggestionController.swift +++ b/Packages/TableProEditor/Sources/TableProEditorKit/CodeSuggestion/Window/SuggestionController.swift @@ -297,6 +297,18 @@ public final class SuggestionController: NSWindowController { guard !activeTextView.textView.hasMarkedText() else { return event } + /// A chord carrying a real modifier is never this panel's. This runs from a local key + /// monitor, which AppKit consults **before** the main menu, so claiming one takes that + /// menu command away app-wide for as long as the panel is open: switching on the key code + /// alone made Command+Return apply a completion instead of running the query, with no + /// feedback and no way to tell from the outside. Caps Lock, Function and the numeric-pad + /// flag are not modifiers here, because a plain arrow key carries the last two. + guard event.modifierFlags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.capsLock, .function, .numericPad]) + .isEmpty + else { return event } + switch Int(event.keyCode) { case kVK_Escape: close() diff --git a/Plugins/SQLiteDriverPlugin/SQLiteAgentBackend.swift b/Plugins/SQLiteDriverPlugin/SQLiteAgentBackend.swift index 4bd14f7376..04d7e21f42 100644 --- a/Plugins/SQLiteDriverPlugin/SQLiteAgentBackend.swift +++ b/Plugins/SQLiteDriverPlugin/SQLiteAgentBackend.swift @@ -480,7 +480,7 @@ final class SQLiteAgentConnection: @unchecked Sendable { private func startHeartbeat() { let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.global()) timer.schedule(deadline: .now() + Self.heartbeatInterval, repeating: Self.heartbeatInterval) - timer.setEventHandler { [weak self] in + timer.setEventHandler { @Sendable [weak self] in self?.sendHeartbeat() } heartbeatTimer = timer diff --git a/TablePro/Core/Coordinators/RowEditingCoordinator.swift b/TablePro/Core/Coordinators/RowEditingCoordinator.swift index 8a46b7114e..322c4c8e66 100644 --- a/TablePro/Core/Coordinators/RowEditingCoordinator.swift +++ b/TablePro/Core/Coordinators/RowEditingCoordinator.swift @@ -251,7 +251,7 @@ final class RowEditingCoordinator: ObservableObject { parent.mutateActiveTableRows(for: tabId) { rows in rows.editMany(edits) } parent.tabManager.mutate(at: tabIndex) { $0.hasUserInteraction = true } repaintInspectorEdit(rowIDs: editedRowIDs, columnIndex: columnIndex, in: tableRows) - parent.inspectorRowContentRevision &+= 1 + parent.inspectorRowContentChanged.send() } /// `editMany` reports the rows it changed by their position in storage, and the grid reads a diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index 378fd7c89e..2c9989de6a 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -78,11 +78,15 @@ final class PluginManager: ObservableObject { private let builtInPluginsURL: URL? internal let userPluginsDir: URL - internal(set) var plugins: [PluginEntry] = [] + /// Every plugin collection here is published. The class was `@Observable` until #2874, which + /// tracked these without a word, and Settings > Plugins and the rejected-plugin banner are + /// written against that: without it an install, an update or a rejection changed nothing on + /// screen until the pane was reopened. + @Published internal(set) var plugins: [PluginEntry] = [] - internal(set) var stagedUpdates: [String: StagedPluginUpdate] = [:] + @Published internal(set) var stagedUpdates: [String: StagedPluginUpdate] = [:] - internal(set) var pluginsWithRegistryUpdate: Set = [] + @Published internal(set) var pluginsWithRegistryUpdate: Set = [] var isInstalling: Bool { PluginInstallTracker.shared.activeInstalls.values.contains { progress in @@ -135,19 +139,19 @@ final class PluginManager: ObservableObject { waiter.continuation.resume() } - internal(set) var rejectedPlugins: [RejectedPlugin] = [] + @Published internal(set) var rejectedPlugins: [RejectedPlugin] = [] @Published var needsRestart: Bool = false - internal(set) var driverPlugins: [String: any DriverPlugin] = [:] + @Published internal(set) var driverPlugins: [String: any DriverPlugin] = [:] - internal(set) var exportPlugins: [String: any ExportFormatPlugin] = [:] + @Published internal(set) var exportPlugins: [String: any ExportFormatPlugin] = [:] - internal(set) var importPlugins: [String: any ImportFormatPlugin] = [:] + @Published internal(set) var importPlugins: [String: any ImportFormatPlugin] = [:] - internal(set) var inspectorPlugins: [String: any DocumentInspectorPlugin] = [:] + @Published internal(set) var inspectorPlugins: [String: any DocumentInspectorPlugin] = [:] - internal(set) var pluginInstances: [String: any TableProPlugin] = [:] + @Published internal(set) var pluginInstances: [String: any TableProPlugin] = [:] var disabledPluginIds: Set { get { Set(defaults.stringArray(forKey: Self.disabledPluginsKey) ?? []) } diff --git a/TablePro/Core/Services/Licensing/LicenseManager.swift b/TablePro/Core/Services/Licensing/LicenseManager.swift index 5be753ddf5..8adb8bc89e 100644 --- a/TablePro/Core/Services/Licensing/LicenseManager.swift +++ b/TablePro/Core/Services/Licensing/LicenseManager.swift @@ -54,8 +54,9 @@ final class LicenseManager: ObservableObject { } } - /// Whether a network operation is in progress - private(set) var isValidating: Bool = false + /// Whether a network operation is in progress. Published, because the License and Sync panes + /// disable their buttons on it while a check runs. + @Published private(set) var isValidating: Bool = false /// Last error from an operation (cleared on success) @Published private(set) var lastError: LicenseError? diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index c757595ef8..22e8a7b76f 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -44,13 +44,15 @@ final class ConnectionStorage { syncTracker: SyncChangeTracker = .shared, appSettings: @escaping @autoclosure () -> AppSettingsStorage = .shared, keychain: any KeychainStoring = AppStorageEnvironment.shared.keychain, - appEvents: @escaping @autoclosure () -> AppEvents = .shared + appEvents: @escaping @autoclosure () -> AppEvents = .shared, + integrity: ConnectionStoreIntegrity = .shared ) { self.file = IntegrityStampedFileStore( fileURL: fileURL, label: "connections.json", logger: Self.logger, - userSaveEstablishesTrust: true + userSaveEstablishesTrust: true, + integrity: integrity ) self.defaults = userDefaults self.syncTracker = syncTracker diff --git a/TablePro/Core/Storage/ConnectionStoreIntegrity.swift b/TablePro/Core/Storage/ConnectionStoreIntegrity.swift index e8b9a89963..606a46b617 100644 --- a/TablePro/Core/Storage/ConnectionStoreIntegrity.swift +++ b/TablePro/Core/Storage/ConnectionStoreIntegrity.swift @@ -68,6 +68,15 @@ struct ConnectionStoreIntegrity: Sendable { } } + static func randomKeyBytes(count: Int) -> Data? { + var bytes = [UInt8](repeating: 0, count: count) + guard SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess else { + logger.error("Could not generate a connection store integrity key") + return nil + } + return Data(bytes) + } + static func constantTimeEquals(_ lhs: Data, _ rhs: Data) -> Bool { guard lhs.count == rhs.count else { return false } var difference: UInt8 = 0 @@ -91,6 +100,10 @@ struct KeychainIntegrityKeySource: IntegrityKeySource { nonisolated(unsafe) private static var cached: SymmetricKey? func key() -> SymmetricKey? { + if let isolatedStore = Self.isolatedStore { + return StoredIntegrityKeySource(store: isolatedStore).key() + } + Self.lock.lock() defer { Self.lock.unlock() } @@ -120,17 +133,7 @@ struct KeychainIntegrityKeySource: IntegrityKeySource { AppStorageEnvironment.shared.isIsolated ? AppStorageEnvironment.shared.keychain : nil } - private static let isolatedKey = "connectionStoreIntegrity" - private static func read() -> SymmetricKey? { - if let isolatedStore { - guard case let .found(encoded) = isolatedStore.readStringResult(forKey: isolatedKey), - let data = Data(base64Encoded: encoded), - data.count == byteCount - else { return nil } - return SymmetricKey(data: data) - } - var query = baseQuery() query[kSecReturnData as String] = true query[kSecMatchLimit as String] = kSecMatchLimitOne @@ -145,22 +148,10 @@ struct KeychainIntegrityKeySource: IntegrityKeySource { } private static func create() -> SymmetricKey? { - var bytes = [UInt8](repeating: 0, count: byteCount) - guard SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess else { - ConnectionStoreIntegrity.logger.error("Could not generate a connection store integrity key") - return nil - } - - if let isolatedStore { - guard isolatedStore.writeString(Data(bytes).base64EncodedString(), forKey: isolatedKey) else { - ConnectionStoreIntegrity.logger.error("Could not store the connection store integrity key") - return nil - } - return SymmetricKey(data: Data(bytes)) - } + guard let bytes = ConnectionStoreIntegrity.randomKeyBytes(count: byteCount) else { return nil } var addQuery = baseQuery() - addQuery[kSecValueData as String] = Data(bytes) + addQuery[kSecValueData as String] = bytes addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly let status = SecItemAdd(addQuery as CFDictionary, nil) @@ -170,6 +161,37 @@ struct KeychainIntegrityKeySource: IntegrityKeySource { ) return nil } - return SymmetricKey(data: Data(bytes)) + return SymmetricKey(data: bytes) + } +} + +/// Holds the key in a `KeychainStoring` rather than in the system keychain. +/// +/// A sandboxed run and a test host both need this: the sandbox keeps its key beside the storage it +/// isolates, and a test host reaches no system keychain item of its own, so `SecItemAdd` fails and +/// every store it writes reads back untrusted. +struct StoredIntegrityKeySource: IntegrityKeySource { + private static let storageKey = "connectionStoreIntegrity" + private static let byteCount = 32 + + private let store: any KeychainStoring + + init(store: any KeychainStoring) { + self.store = store + } + + func key() -> SymmetricKey? { + if case let .found(encoded) = store.readStringResult(forKey: Self.storageKey), + let data = Data(base64Encoded: encoded), + data.count == Self.byteCount { + return SymmetricKey(data: data) + } + + guard let bytes = ConnectionStoreIntegrity.randomKeyBytes(count: Self.byteCount) else { return nil } + guard store.writeString(bytes.base64EncodedString(), forKey: Self.storageKey) else { + ConnectionStoreIntegrity.logger.error("Could not store the connection store integrity key") + return nil + } + return SymmetricKey(data: bytes) } } diff --git a/TablePro/Core/Storage/CredentialProfileStorage.swift b/TablePro/Core/Storage/CredentialProfileStorage.swift index 14376d5dea..1964f0ce34 100644 --- a/TablePro/Core/Storage/CredentialProfileStorage.swift +++ b/TablePro/Core/Storage/CredentialProfileStorage.swift @@ -45,13 +45,15 @@ final class CredentialProfileStorage { fileURL: URL = CredentialProfileStorage.defaultFileURL(), keychain: any KeychainStoring = AppStorageEnvironment.shared.keychain, syncTracker: SyncChangeTracker = .shared, - connectionStorage: @escaping @autoclosure () -> ConnectionStorage = .shared + connectionStorage: @escaping @autoclosure () -> ConnectionStorage = .shared, + integrity: ConnectionStoreIntegrity = .shared ) { self.file = IntegrityStampedFileStore( fileURL: fileURL, label: "credentialProfiles.json", logger: Self.logger, - userSaveEstablishesTrust: false + userSaveEstablishesTrust: false, + integrity: integrity ) self.keychain = keychain self.syncTracker = syncTracker diff --git a/TablePro/Core/Storage/HighlightRuleStorage.swift b/TablePro/Core/Storage/HighlightRuleStorage.swift index a67772dd3e..b775a925ee 100644 --- a/TablePro/Core/Storage/HighlightRuleStorage.swift +++ b/TablePro/Core/Storage/HighlightRuleStorage.swift @@ -37,8 +37,7 @@ final class HighlightRuleStorage: ObservableObject, TableScopedSettingsStore { } func rules(for scope: TableScope) -> [HighlightRule] { - _ = revision - return loadEntries(for: scope.connectionId)[scope.storageComponent] ?? [] + loadEntries(for: scope.connectionId)[scope.storageComponent] ?? [] } func setRules(_ rules: [HighlightRule], for scope: TableScope) { diff --git a/TablePro/Models/UI/HistoryPanelState.swift b/TablePro/Models/UI/HistoryPanelState.swift index 0271d82503..9870cba7e5 100644 --- a/TablePro/Models/UI/HistoryPanelState.swift +++ b/TablePro/Models/UI/HistoryPanelState.swift @@ -5,12 +5,17 @@ import Foundation final class HistoryPanelState: ObservableObject { let connectionId: UUID - var isVisible: Bool { didSet { persistIfChanged(oldValue != isVisible) } } - var showsAllConnections: Bool { didSet { persistIfChanged(oldValue != showsAllConnections) } } - var pinnedConnectionId: UUID? { didSet { persistIfChanged(oldValue != pinnedConnectionId) } } - var sources: Set { didSet { persistIfChanged(oldValue != sources) } } - var dateRange: HistoryDateRange { didSet { persistIfChanged(oldValue != dateRange) } } - var outcome: QueryHistoryOutcome { didSet { persistIfChanged(oldValue != outcome) } } + /// Every one of these is `@Published`, because each is read from a SwiftUI body and a plain + /// `var` on an `ObservableObject` announces nothing. `isVisible` shipped as the one that shows: + /// Cmd+Y wrote it, `MainEditorContentView` never re-evaluated, and the drawer stayed shut until + /// some unrelated change redrew the window. Driving the same command from the menu bar hid it, + /// because menu tracking itself forces that redraw. + @Published var isVisible: Bool { didSet { persistIfChanged(oldValue != isVisible) } } + @Published var showsAllConnections: Bool { didSet { persistIfChanged(oldValue != showsAllConnections) } } + @Published var pinnedConnectionId: UUID? { didSet { persistIfChanged(oldValue != pinnedConnectionId) } } + @Published var sources: Set { didSet { persistIfChanged(oldValue != sources) } } + @Published var dateRange: HistoryDateRange { didSet { persistIfChanged(oldValue != dateRange) } } + @Published var outcome: QueryHistoryOutcome { didSet { persistIfChanged(oldValue != outcome) } } /// Search text is deliberately not persisted: a stale query on relaunch reads as an empty /// history rather than as a filter the user forgot they left behind. diff --git a/TablePro/Views/AIChat/AIChatCodeBlockView.swift b/TablePro/Views/AIChat/AIChatCodeBlockView.swift index c9de2f144c..c3ad7ed85f 100644 --- a/TablePro/Views/AIChat/AIChatCodeBlockView.swift +++ b/TablePro/Views/AIChat/AIChatCodeBlockView.swift @@ -9,6 +9,7 @@ import TableProEditorKit import TableProGrammars struct AIChatCodeBlockView: View, Equatable { + @ObservedObject private var settingsManager = AppSettingsManager.shared let code: String let language: String? var prefersLightweightRendering: Bool = false diff --git a/TablePro/Views/AIChat/AIChatPanelView.swift b/TablePro/Views/AIChat/AIChatPanelView.swift index 9a4a28b88a..2880712d5c 100644 --- a/TablePro/Views/AIChat/AIChatPanelView.swift +++ b/TablePro/Views/AIChat/AIChatPanelView.swift @@ -9,6 +9,7 @@ import SwiftUI /// AI chat panel displayed alongside the main editor content struct AIChatPanelView: View { + @ObservedObject private var slashCommandStorage = CustomSlashCommandStorage.shared private static let warningBackgroundOpacity: Double = 0.1 let connection: DatabaseConnection @@ -466,7 +467,7 @@ struct AIChatPanelView: View { } private var slashCommandMenu: some View { - let customCommands = CustomSlashCommandStorage.shared.commands.filter(\.isValid) + let customCommands = slashCommandStorage.commands.filter(\.isValid) return Menu { ForEach(SlashCommand.allCommands) { command in Button { diff --git a/TablePro/Views/AIChat/AIChatToolUseBlockView.swift b/TablePro/Views/AIChat/AIChatToolUseBlockView.swift index 6fbc109562..3ef25053b6 100644 --- a/TablePro/Views/AIChat/AIChatToolUseBlockView.swift +++ b/TablePro/Views/AIChat/AIChatToolUseBlockView.swift @@ -7,6 +7,7 @@ import AppKit import SwiftUI struct AIChatToolUseBlockView: View { + @ObservedObject private var themeEngine = ThemeEngine.shared let block: ToolUseBlock @State private var isExpanded: Bool = false @@ -58,7 +59,7 @@ struct AIChatToolUseBlockView: View { if isPending, let proposedStatement { ScrollView(.horizontal, showsIndicators: false) { Text(proposedStatement) - .font(ThemeEngine.shared.valueFontSwiftUI) + .font(themeEngine.valueFontSwiftUI) .textSelection(.enabled) .padding(8) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/TablePro/Views/Components/DiagramZoomToolbar.swift b/TablePro/Views/Components/DiagramZoomToolbar.swift index a0315fb1d5..1c74411f82 100644 --- a/TablePro/Views/Components/DiagramZoomToolbar.swift +++ b/TablePro/Views/Components/DiagramZoomToolbar.swift @@ -8,7 +8,12 @@ import SwiftUI struct DiagramZoomToolbar: View { - let viewport: DiagramViewportController + /// Observed here rather than trusted to a parent. The percentage and both buttons' enabled + /// state are read from it in this body, and the plan diagram holds its viewport as a plain + /// property, so on a plan every zoom changed the canvas and left the readout on the old + /// percentage with Zoom In and Zoom Out dimmed as they were. The ER diagram hid it only because + /// its own toolbar happens to observe the same object and redraws this one along with it. + @ObservedObject var viewport: DiagramViewportController @ViewBuilder let extras: () -> Extras init(viewport: DiagramViewportController, @ViewBuilder extras: @escaping () -> Extras = { EmptyView() }) { diff --git a/TablePro/Views/Components/PaginationControlsView.swift b/TablePro/Views/Components/PaginationControlsView.swift index b3416c3609..14db7bf6aa 100644 --- a/TablePro/Views/Components/PaginationControlsView.swift +++ b/TablePro/Views/Components/PaginationControlsView.swift @@ -6,6 +6,7 @@ import SwiftUI struct PaginationControlsView: View { + @ObservedObject private var settingsManager = AppSettingsManager.shared let pagination: PaginationState let loadedRowCount: Int /// Identity of the tab these controls describe. Not used for display: a change to it is what @@ -188,7 +189,7 @@ struct PaginationControlsView: View { } private func helpText(_ label: String, for shortcut: ShortcutAction) -> String { - AppSettingsManager.shared.keyboard.shortcutHint(label, for: shortcut) + settingsManager.keyboard.shortcutHint(label, for: shortcut) } /// A button straight to the jump popover while rows-per-page has its own control, and a menu diff --git a/TablePro/Views/Components/SQLStatementPreview.swift b/TablePro/Views/Components/SQLStatementPreview.swift index fccbd9b90e..9b53570a04 100644 --- a/TablePro/Views/Components/SQLStatementPreview.swift +++ b/TablePro/Views/Components/SQLStatementPreview.swift @@ -16,6 +16,7 @@ import TableProPluginKit /// a detail of the review sheet: a second surface that picked differently would show the same /// `CREATE SCHEMA` two ways. struct SQLStatementPreview: View { + @ObservedObject private var settingsManager = AppSettingsManager.shared let prepared: SQLReviewSheet.Prepared let databaseType: DatabaseType diff --git a/TablePro/Views/Connection/ConnectionExportOptionsSheet.swift b/TablePro/Views/Connection/ConnectionExportOptionsSheet.swift index c3cb1f9189..0c86733d73 100644 --- a/TablePro/Views/Connection/ConnectionExportOptionsSheet.swift +++ b/TablePro/Views/Connection/ConnectionExportOptionsSheet.swift @@ -8,6 +8,7 @@ import TableProImport import UniformTypeIdentifiers struct ConnectionExportOptionsSheet: View { + @ObservedObject private var licenseManager = LicenseManager.shared let connections: [DatabaseConnection] @Environment(\.dismiss) private var dismiss @@ -19,7 +20,7 @@ struct ConnectionExportOptionsSheet: View { @State private var exportError: String? private var isProAvailable: Bool { - LicenseManager.shared.isFeatureAvailable(.encryptedExport) + licenseManager.isFeatureAvailable(.encryptedExport) } private var passphraseState: ConnectionExportPassphraseState { diff --git a/TablePro/Views/Connection/ImportFromAWS/AWSDiscoveryProgressStep.swift b/TablePro/Views/Connection/ImportFromAWS/AWSDiscoveryProgressStep.swift index ca46edac41..039cff89e2 100644 --- a/TablePro/Views/Connection/ImportFromAWS/AWSDiscoveryProgressStep.swift +++ b/TablePro/Views/Connection/ImportFromAWS/AWSDiscoveryProgressStep.swift @@ -1,7 +1,10 @@ import SwiftUI struct AWSDiscoveryProgressStep: View { - let session: AWSDiscoverySession + /// Observed, because every row reads `regionProgress` and the discovery writes it region by + /// region while this step is on screen. Held as a plain property the rows were drawn once, as + /// pending, and stayed that way until the step was replaced. + @ObservedObject var session: AWSDiscoverySession let onCancel: () -> Void var body: some View { diff --git a/TablePro/Views/Connection/ImportFromAWS/ImportFromAWSSheet.swift b/TablePro/Views/Connection/ImportFromAWS/ImportFromAWSSheet.swift index 5c06f3e03d..8eaca30f3a 100644 --- a/TablePro/Views/Connection/ImportFromAWS/ImportFromAWSSheet.swift +++ b/TablePro/Views/Connection/ImportFromAWS/ImportFromAWSSheet.swift @@ -3,10 +3,11 @@ import TableProImport import TableProPluginKit struct ImportFromAWSSheet: View { + @ObservedObject private var pluginManager = PluginManager.shared var onImported: ((Int) -> Void)? @Environment(\.dismiss) private var dismiss - @State private var session = AWSDiscoverySession() + @StateObject private var session = AWSDiscoverySession() @State private var step: Step = .configure @State private var discoveryToken = 0 @@ -167,7 +168,7 @@ struct ImportFromAWSSheet: View { ) return RDSDiscoveryReconciler.markingMissingDrivers(analyzed) { typeId in let type = DatabaseType(rawValue: typeId) - guard case .notInstalled = PluginManager.shared.driverUnavailability(for: type) else { return nil } + guard case .notInstalled = pluginManager.driverUnavailability(for: type) else { return nil } return PluginManager.registryDisplayName(of: type) } } diff --git a/TablePro/Views/Connection/PluginRejectedBannerModifier.swift b/TablePro/Views/Connection/PluginRejectedBannerModifier.swift index 03ead5d192..36ea35a203 100644 --- a/TablePro/Views/Connection/PluginRejectedBannerModifier.swift +++ b/TablePro/Views/Connection/PluginRejectedBannerModifier.swift @@ -7,9 +7,12 @@ import SwiftUI struct PluginRejectedBannerModifier: ViewModifier { let databaseType: DatabaseType - private let pluginManager = PluginManager.shared - private let registryClient = RegistryClient.shared - private let installTracker = PluginInstallTracker.shared + /// Observed, all three. The banner shows and hides on `rejectedPlugins` and draws install progress + /// from the other two; held as plain constants it kept the state it had when the form opened, so + /// a plugin updated from the banner went on being reported as rejected. + @ObservedObject private var pluginManager = PluginManager.shared + @ObservedObject private var registryClient = RegistryClient.shared + @ObservedObject private var installTracker = PluginInstallTracker.shared @State private var errorMessage: String? @State private var showError = false diff --git a/TablePro/Views/Connection/TypeChooser/DatabaseTypeChooserSheet.swift b/TablePro/Views/Connection/TypeChooser/DatabaseTypeChooserSheet.swift index 72efcd0c20..22b8ba735f 100644 --- a/TablePro/Views/Connection/TypeChooser/DatabaseTypeChooserSheet.swift +++ b/TablePro/Views/Connection/TypeChooser/DatabaseTypeChooserSheet.swift @@ -152,6 +152,7 @@ struct DatabaseTypeChooserSheet: View { } private struct DatabaseTypeChooserRow: View { + @ObservedObject private var pluginManager = PluginManager.shared let type: DatabaseType let isCurrent: Bool @@ -194,6 +195,6 @@ private struct DatabaseTypeChooserRow: View { } private var shouldShowNotInstalledBadge: Bool { - type.isDownloadablePlugin && !PluginManager.shared.isDriverInstalled(for: type) + type.isDownloadablePlugin && !pluginManager.isDriverInstalled(for: type) } } diff --git a/TablePro/Views/ConnectionForm/Panes/OptionsPaneView.swift b/TablePro/Views/ConnectionForm/Panes/OptionsPaneView.swift index 606bc7b977..3abeb8c123 100644 --- a/TablePro/Views/ConnectionForm/Panes/OptionsPaneView.swift +++ b/TablePro/Views/ConnectionForm/Panes/OptionsPaneView.swift @@ -13,10 +13,11 @@ import TableProPluginKit /// three sidebar panes called Customization, Advanced and AI Rules. They answer one question, so /// they are one tab. struct OptionsPaneView: View { + @ObservedObject private var settingsManager = AppSettingsManager.shared @ObservedObject var coordinator: ConnectionFormCoordinator private var databaseType: DatabaseType { coordinator.network.type } - private var aiIsEnabled: Bool { AppSettingsManager.shared.ai.enabled } + private var aiIsEnabled: Bool { settingsManager.ai.enabled } var body: some View { Form { @@ -27,7 +28,7 @@ struct OptionsPaneView: View { if aiIsEnabled { aiRulesSection } - if AppSettingsManager.shared.sync.enabled { + if settingsManager.sync.enabled { syncSection } } diff --git a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift index 4d46e085fd..0617a62967 100644 --- a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift +++ b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift @@ -3,6 +3,7 @@ import SwiftUI import TableProPluginKit struct DatabaseSwitcherPopoverHost: View { + @ObservedObject private var databaseManager = DatabaseManager.shared weak var coordinator: MainContentCoordinator? /// Which container dimension this presentation switches. An engine can have both, so the caller /// names the one it opened rather than the popover guessing from the engine's primary target. @@ -12,7 +13,7 @@ struct DatabaseSwitcherPopoverHost: View { var body: some View { if let coordinator { let connection = coordinator.connection - let session = DatabaseManager.shared.session(for: connection.id) + let session = databaseManager.session(for: connection.id) let switchTarget = target ?? PluginManager.shared.containerSwitchTarget(for: connection.type) ?? .database diff --git a/TablePro/Views/Editor/Folding/FoldPreviewView.swift b/TablePro/Views/Editor/Folding/FoldPreviewView.swift index 28ab3c19db..e594b6beee 100644 --- a/TablePro/Views/Editor/Folding/FoldPreviewView.swift +++ b/TablePro/Views/Editor/Folding/FoldPreviewView.swift @@ -19,6 +19,7 @@ import TableProGrammars /// content that simply fills it reads as stray text drawn over the code rather than as a panel floating above it. The /// code keeps the theme's editor background so its colours stay legible, and a hairline marks where the panel ends. struct FoldPreviewView: View { + @ObservedObject private var themeEngine = ThemeEngine.shared let layout: FoldPreviewMetrics.Layout let language: CodeLanguage @@ -51,7 +52,7 @@ struct FoldPreviewView: View { .padding(.vertical, 5) } } - .background(ThemeEngine.shared.colors.editor.backgroundSwiftUI) + .background(themeEngine.colors.editor.backgroundSwiftUI) .clipShape(RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous) diff --git a/TablePro/Views/Editor/History/HistoryDetailPane.swift b/TablePro/Views/Editor/History/HistoryDetailPane.swift index 5ea262a2e7..99056c8def 100644 --- a/TablePro/Views/Editor/History/HistoryDetailPane.swift +++ b/TablePro/Views/Editor/History/HistoryDetailPane.swift @@ -1,7 +1,35 @@ import SwiftUI import TableProPluginKit +/// Reads the selection off the model itself so a click repaints the detail. +/// +/// `HistoryPanelView` keeps the model in `@State`, which stores the reference and subscribes to +/// nothing. Reading `selectedEntry` in that view's own `body` therefore built this pane once, with +/// no entry, and nothing ever rebuilt it: clicking a row selected it in the list and the detail +/// went on saying "No Query Selected". The list and the toolbar were never wrong because each +/// observes the model for itself, which is what this does. +struct HistorySelectedDetailPane: View { + @ObservedObject var viewModel: HistoryPanelViewModel + + let canRunInNewTab: (QueryHistoryEntry) -> Bool + let onLoadInEditor: (QueryHistoryEntry) -> Void + let onRunInNewTab: (QueryHistoryEntry) -> Void + let onCopy: (QueryHistoryEntry) -> Void + + var body: some View { + HistoryDetailPane( + entry: viewModel.selectedEntry, + connectionLabel: viewModel.selectedEntry.flatMap { viewModel.connectionLabel(for: $0) }, + canRunInNewTab: viewModel.selectedEntry.map { canRunInNewTab($0) } ?? false, + onLoadInEditor: onLoadInEditor, + onRunInNewTab: onRunInNewTab, + onCopy: onCopy + ) + } +} + struct HistoryDetailPane: View { + @ObservedObject private var themeEngine = ThemeEngine.shared let entry: QueryHistoryEntry? let connectionLabel: HistoryConnectionLabel? let canRunInNewTab: Bool @@ -37,7 +65,7 @@ struct HistoryDetailPane: View { databaseType: entry.databaseType, accessibilityIdentifier: "query-history-detail-query" ) - .background(Color(nsColor: ThemeEngine.shared.colors.editor.background)) + .background(Color(nsColor: themeEngine.colors.editor.background)) .frame(maxWidth: .infinity, maxHeight: .infinity) Divider() diff --git a/TablePro/Views/Editor/History/HistoryPanelView.swift b/TablePro/Views/Editor/History/HistoryPanelView.swift index 6f05f24786..c92d3cf43d 100644 --- a/TablePro/Views/Editor/History/HistoryPanelView.swift +++ b/TablePro/Views/Editor/History/HistoryPanelView.swift @@ -78,10 +78,9 @@ struct HistoryPanelView: View { onRestorePreviousValues: { coordinator.rewindSave(historyId: $0.id) } ) } secondary: { - HistoryDetailPane( - entry: viewModel.selectedEntry, - connectionLabel: viewModel.selectedEntry.flatMap { viewModel.connectionLabel(for: $0) }, - canRunInNewTab: viewModel.selectedEntry.map { canRun($0) } ?? false, + HistorySelectedDetailPane( + viewModel: viewModel, + canRunInNewTab: { canRun($0) }, onLoadInEditor: { load($0) }, onRunInNewTab: { runInNewTab($0) }, onCopy: { copy($0) } diff --git a/TablePro/Views/Editor/QueryEditorView.swift b/TablePro/Views/Editor/QueryEditorView.swift index ae294b8fcb..76ba1b16d6 100644 --- a/TablePro/Views/Editor/QueryEditorView.swift +++ b/TablePro/Views/Editor/QueryEditorView.swift @@ -9,6 +9,7 @@ import TableProPluginKit /// The SQL editor, its command bar, and the banners that belong to the document it holds. struct QueryEditorView: View { + @ObservedObject private var settingsManager = AppSettingsManager.shared @Binding var queryText: String @Binding var cursorPositions: [CursorPosition] @Binding var parameters: [QueryParameter] @@ -56,7 +57,7 @@ struct QueryEditorView: View { scope: scope, commands: commands, isExecuting: isExecuting, - vimMode: AppSettingsManager.shared.editor.vimModeEnabled ? vimMode : nil, + vimMode: settingsManager.editor.vimModeEnabled ? vimMode : nil, showsHistoryTip: showsHistoryTip, onRun: onRun, onRunAllStatements: onRunAllStatements, diff --git a/TablePro/Views/Editor/SQLEditorView.swift b/TablePro/Views/Editor/SQLEditorView.swift index 0d72ab3062..388051b48a 100644 --- a/TablePro/Views/Editor/SQLEditorView.swift +++ b/TablePro/Views/Editor/SQLEditorView.swift @@ -17,9 +17,12 @@ import TableProTextEngine /// SwiftUI SQL editor powered by TableProEditorKit struct SQLEditorView: View { + @ObservedObject private var settingsManager = AppSettingsManager.shared + @ObservedObject private var themeEngine = ThemeEngine.shared @Binding var text: String @Binding var cursorPositions: [CursorPosition] @State private var completionProfile: QueryCompletionProfile? + @State private var observedProfileRevision = 0 var schemaProvider: SQLSchemaProvider? var databaseType: DatabaseType? var databaseScope: DatabaseScope? @@ -58,7 +61,7 @@ struct SQLEditorView: View { coordinator.onExecuteQuery = onExecuteQuery coordinator.onRunStatement = onRunStatement coordinator.setStatementRunControlsEnabled(!isExecuting) - coordinator.setStatementHighlightEnabled(AppSettingsManager.shared.editor.highlightCurrentStatement) + coordinator.setStatementHighlightEnabled(settingsManager.editor.highlightCurrentStatement) coordinator.onAIExplain = onAIExplain coordinator.onAIOptimize = onAIOptimize coordinator.onSaveAsFavorite = onSaveAsFavorite @@ -133,13 +136,16 @@ struct SQLEditorView: View { completionProfile = nil configureCompletion() } + .onReceive(completionRevisionChanges) { revision in + observedProfileRevision = revision + } .task(id: completionProfileRequest) { await resolveCompletionProfile() } .onChange(of: colorScheme) { _ in editorConfiguration = Self.makeConfiguration() } - .onChange(of: AppSettingsManager.shared.editor) { _ in + .onChange(of: settingsManager.editor) { _ in editorConfiguration = Self.makeConfiguration() } .onReceive(AppEvents.shared.accessibilityTextSizeChanged) { _ in @@ -178,14 +184,24 @@ struct SQLEditorView: View { ) } - /// Reading `revision` here is what subscribes this body to its own scope's invalidations, and - /// only its own: the registry is not `@Observable`, so the dependency lands on this one box. + /// This scope's box alone, never the registry. The registry publishes on every fetch, and an + /// editor redrawn for each of them would redraw while the user types. A `@Published` publisher + /// delivers its current value on subscribe, so the key starts in step with the box. + private var completionRevisionChanges: AnyPublisher { + guard let databaseScope else { return Empty().eraseToAnyPublisher() } + return QueryCompletionProfileRegistry.shared.revisionBox(for: databaseScope).$revision + .eraseToAnyPublisher() + } + + /// The revision is part of the key, so an invalidation of this scope restarts the resolution. + /// It is the value `completionRevisionChanges` last delivered, not a read of the box: the box is + /// an `ObservableObject`, and reading it from a body subscribes nothing. private var completionProfileRequest: CompletionProfileRequest? { guard let databaseScope, let databaseType else { return nil } return CompletionProfileRequest( scope: databaseScope, databaseType: databaseType, - profileRevision: QueryCompletionProfileRegistry.shared.revisionBox(for: databaseScope).revision + profileRevision: observedProfileRevision ) } diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index bcb4149892..93d17d1452 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -10,6 +10,7 @@ import TableProPluginKit import UniformTypeIdentifiers struct ExportDialog: View { + @ObservedObject private var pluginManager = PluginManager.shared private static let logger = Logger(subsystem: "com.TablePro", category: "ExportDialog") @Binding var isPresented: Bool @@ -184,7 +185,7 @@ struct ExportDialog: View { private var availableFormats: [any ExportFormatPlugin] { let dbTypeId = connection.type.rawValue - let supported = PluginManager.shared.allExportPlugins() + let supported = pluginManager.allExportPlugins() .filter { plugin in let pluginType = type(of: plugin) if !pluginType.supportedDatabaseTypeIds.isEmpty { @@ -203,7 +204,7 @@ struct ExportDialog: View { } private var currentPlugin: (any ExportFormatPlugin)? { - PluginManager.shared.exportPlugin(forFormat: config.formatId) + pluginManager.exportPlugin(forFormat: config.formatId) } private var currentOptionColumnCount: Int { @@ -393,7 +394,7 @@ struct ExportDialog: View { Picker(String(localized: "Format"), selection: $config.formatId) { ForEach(availableFormatIds, id: \.self) { formatId in - if let plugin = PluginManager.shared.exportPlugin(forFormat: formatId) { + if let plugin = pluginManager.exportPlugin(forFormat: formatId) { Text(type(of: plugin).formatDisplayName).tag(formatId) } } diff --git a/TablePro/Views/Export/ExportRowScopeEditor.swift b/TablePro/Views/Export/ExportRowScopeEditor.swift index 67d9f41ba2..a7a9b1e403 100644 --- a/TablePro/Views/Export/ExportRowScopeEditor.swift +++ b/TablePro/Views/Export/ExportRowScopeEditor.swift @@ -12,6 +12,7 @@ import TableProPluginKit /// statement: an expression this dialog rejected would have to be a dialect check for every engine /// TablePro speaks, and the server's own error is a better one than any of them. internal struct ExportRowScopeEditor: View { + @ObservedObject private var themeEngine = ThemeEngine.shared internal let objectName: String internal let availableColumns: [String] @Binding internal var scope: PluginExportRowScope @@ -39,7 +40,7 @@ internal struct ExportRowScopeEditor: View { TextField("status = 'active'", text: $filter, axis: .vertical) .textFieldStyle(.roundedBorder) .lineLimit(2 ... 4) - .font(ThemeEngine.shared.valueFontSwiftUI) + .font(themeEngine.valueFontSwiftUI) .accessibilityIdentifier("row-scope-filter") if hasRejectedFilter { Text("A filter is one expression. Remove the semicolon.") diff --git a/TablePro/Views/Export/TableTransferSheet.swift b/TablePro/Views/Export/TableTransferSheet.swift index 9d93e654c9..2c488a013c 100644 --- a/TablePro/Views/Export/TableTransferSheet.swift +++ b/TablePro/Views/Export/TableTransferSheet.swift @@ -14,6 +14,7 @@ import TableProPluginKit /// DDL into another's, which is a different problem, and getting it half right would leave tables /// whose column types quietly disagree with the data now in them. struct TableTransferSheet: View { + @ObservedObject private var databaseManager = DatabaseManager.shared private static let logger = Logger(subsystem: "com.TablePro", category: "TableTransferSheet") @Binding var isPresented: Bool @@ -294,7 +295,7 @@ struct TableTransferSheet: View { /// execution gate, so the list is where that policy has to hold. @MainActor private func load() async { - availableDestinations = DatabaseManager.shared.activeSessions.values + availableDestinations = databaseManager.activeSessions.values .filter { $0.id != sourceConnection.id && $0.isConnected } .map { $0.effectiveConnection ?? $0.connection } .filter { !$0.safeModeLevel.blocksAllWrites } diff --git a/TablePro/Views/Import/ImportDialog.swift b/TablePro/Views/Import/ImportDialog.swift index 8a1c38411d..9a95a1816a 100644 --- a/TablePro/Views/Import/ImportDialog.swift +++ b/TablePro/Views/Import/ImportDialog.swift @@ -13,6 +13,7 @@ import TableProPluginKit import UniformTypeIdentifiers struct ImportDialog: View { + @ObservedObject private var pluginManager = PluginManager.shared private static let logger = Logger(subsystem: "com.TablePro", category: "ImportDialog") @Binding var isPresented: Bool let connection: DatabaseConnection @@ -153,7 +154,7 @@ struct ImportDialog: View { /// configured for row import" once the user pressed Import. private var availableFormats: [any ImportFormatPlugin] { let dbTypeId = connection.type.rawValue - return PluginManager.shared.allImportPlugins() + return pluginManager.allImportPlugins() .filter { plugin in let pluginType = type(of: plugin) return ImportRouting.isStatementFormat( @@ -167,7 +168,7 @@ struct ImportDialog: View { } private var currentPlugin: (any ImportFormatPlugin)? { - PluginManager.shared.importPlugin(forFormat: selectedFormatId) + pluginManager.importPlugin(forFormat: selectedFormatId) } // MARK: - View Components diff --git a/TablePro/Views/Import/RowImportSheet.swift b/TablePro/Views/Import/RowImportSheet.swift index 0994ceba67..79f11be922 100644 --- a/TablePro/Views/Import/RowImportSheet.swift +++ b/TablePro/Views/Import/RowImportSheet.swift @@ -15,6 +15,7 @@ import SwiftUI import TableProPluginKit struct RowImportSheet: View { + @ObservedObject private var pluginManager = PluginManager.shared private static let logger = Logger(subsystem: "com.TablePro", category: "RowImportSheet") @Binding var isPresented: Bool @@ -641,7 +642,7 @@ struct RowImportSheet: View { // MARK: - Plugin private var currentPlugin: (any ImportFormatPlugin)? { - PluginManager.shared.importPlugin(forFormat: formatId) + pluginManager.importPlugin(forFormat: formatId) } private var canImport: Bool { diff --git a/TablePro/Views/Import/SQLCodePreview.swift b/TablePro/Views/Import/SQLCodePreview.swift index 7aeab9f17e..2348355942 100644 --- a/TablePro/Views/Import/SQLCodePreview.swift +++ b/TablePro/Views/Import/SQLCodePreview.swift @@ -11,6 +11,8 @@ import TableProGrammars /// Read-only SQL code preview with syntax highlighting powered by TableProEditorKit struct SQLCodePreview: View { + @ObservedObject private var settingsManager = AppSettingsManager.shared + @ObservedObject private var themeEngine = ThemeEngine.shared @Binding var text: String @State private var editorState = SourceEditorState() diff --git a/TablePro/Views/Integrations/IntegrationsActivityLogPane.swift b/TablePro/Views/Integrations/IntegrationsActivityLogPane.swift index cbc2367a57..9094b78e89 100644 --- a/TablePro/Views/Integrations/IntegrationsActivityLogPane.swift +++ b/TablePro/Views/Integrations/IntegrationsActivityLogPane.swift @@ -8,6 +8,7 @@ import SwiftUI import UniformTypeIdentifiers struct IntegrationsActivityLogPane: View { + @ObservedObject private var mcpServerManager = MCPServerManager.shared @State private var entries: [AuditEntry] = [] @State private var tokens: [MCPAuthToken] = [] @State private var connections: [DatabaseConnection] = [] @@ -212,7 +213,7 @@ struct IntegrationsActivityLogPane: View { hasLoaded = true } - if let store = MCPServerManager.shared.tokenStore { + if let store = mcpServerManager.tokenStore { tokens = await store.list().filter { !$0.isBridgeCredential } } connections = ConnectionStorage.shared.loadConnections() diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 6413a9fb1c..ff8856d59d 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -21,6 +21,9 @@ private struct TabLoadKey: Hashable { } struct MainEditorContentView: View { + @ObservedObject private var schemaService = SchemaService.shared + @ObservedObject private var licenseManager = LicenseManager.shared + @ObservedObject private var settingsManager = AppSettingsManager.shared /// A query tab nests its own editor/results split, whose two minimums are required constraints. /// The drawer's own minimum has to clear their sum, or dragging the drawer down asks AppKit to /// satisfy a height the content it contains cannot reach. @@ -70,6 +73,11 @@ struct MainEditorContentView: View { @State private var dataTabDelegate = DataTabGridDelegate() @ObservedObject private var treeService = DatabaseTreeMetadataService.shared + /// A table's highlight rules live in this store, not on the tab, and `body` reads them for both + /// the grid and the status bar popover. Without observing it here a rule added to a table tab was + /// written and never shown: Add Rule did nothing visible, while a query result's rules, kept on + /// the tab, updated as expected. + @ObservedObject private var highlightRuleStorage = HighlightRuleStorage.shared // Native macOS window tabs — no LRU tracking needed (single tab per window) @@ -451,7 +459,7 @@ struct MainEditorContentView: View { databaseType: coordinator.connection.type, databaseScope: queryScope, connectionId: coordinator.connection.id, - connectionAIPolicy: coordinator.connection.aiPolicy ?? AppSettingsManager.shared.ai.defaultConnectionPolicy, + connectionAIPolicy: coordinator.connection.aiPolicy ?? settingsManager.ai.defaultConnectionPolicy, tabID: tab.id, claimFocusOnAppear: claimFocus, onFocusClaimed: { @@ -613,7 +621,7 @@ struct MainEditorContentView: View { if let error = tab.display.activeResultSet?.errorMessage ?? tab.execution.errorMessage { InlineErrorBanner( message: error, - onFixWithAI: AppSettingsManager.shared.ai.enabled && tab.tabType == .query + onFixWithAI: settingsManager.ai.enabled && tab.tabType == .query ? { coordinator.fixErrorWithAI(query: tab.execution.errorQuery ?? tab.content.query, error: error) } : nil, onDismiss: { @@ -771,7 +779,7 @@ struct MainEditorContentView: View { tabId: tab.id, resultSetId: resultSet.id, dataRevision: coordinator.tabSessionRegistry.session(for: tab.id)?.dataRevision ?? 0, - isUnlocked: LicenseManager.shared.isFeatureAvailable(.resultCharts) + isUnlocked: licenseManager.isFeatureAvailable(.resultCharts) ) } case .map: @@ -953,7 +961,7 @@ struct MainEditorContentView: View { schemaName: tab.tableContext.schemaName, primaryKeyColumns: changeManager.primaryKeyColumns, tabType: tab.tabType, - showRowNumbers: AppSettingsManager.shared.dataGrid.showRowNumbers, + showRowNumbers: settingsManager.dataGrid.showRowNumbers, hiddenColumns: tab.columnLayout.hiddenColumns, appliesRowSortPreferences: true, editRefusalMessage: refusal?.message @@ -1111,7 +1119,7 @@ struct MainEditorContentView: View { lastTiming: coordinator.toolbarState.queryTiming(forTab: tab.id), onCancel: { coordinator.cancelCurrentQuery() } ), - isRefreshingSchema: SchemaService.shared.isRefreshing(connectionId: connectionId), + isRefreshingSchema: schemaService.isRefreshing(connectionId: connectionId), viewMode: resultsViewModeBinding(for: tab), resultSetMenu: resultSetMenuModel(for: tab), onActivateResultSet: { coordinator.switchActiveResultSet(to: $0, in: tab.id) }, @@ -1178,7 +1186,7 @@ struct MainEditorContentView: View { hasResults: coordinator.canClearActiveQueryResults, explainVariants: coordinator.connection.type.explainVariants, shortcutHint: { label, action in - AppSettingsManager.shared.keyboard.shortcutHint(label, for: action) + settingsManager.keyboard.shortcutHint(label, for: action) } ) } diff --git a/TablePro/Views/Main/Extensions/MainContentCommandActions+Switchers.swift b/TablePro/Views/Main/Extensions/MainContentCommandActions+Switchers.swift new file mode 100644 index 0000000000..9045b1b931 --- /dev/null +++ b/TablePro/Views/Main/Extensions/MainContentCommandActions+Switchers.swift @@ -0,0 +1,73 @@ +// +// MainContentCommandActions+Switchers.swift +// TablePro +// + +import AppKit +import SwiftUI +import TableProPluginKit + +internal extension MainContentCommandActions { + func openDatabaseSwitcher() { + openScopeSwitcher(nil) + } + + /// The one way into the container chooser, for either scope. It used to have two, and the + /// second skipped the session gate the first applies: the centred toolbar chip opened the + /// chooser over a session the health monitor had given up on, while the button 200pt away and + /// the menu command were both correctly disabled. A chooser with one entry point cannot drift + /// from itself. + /// + /// `nil` means the engine's primary container, which is what a command with no scope named can + /// mean. + func openScopeSwitcher(_ target: ContainerSwitchTarget?) { + guard let coordinator, canSwitchContainer(target, on: coordinator) else { return } + /// Clearing first responder is what lets the popover's search field take focus. + coordinator.contentWindow?.makeFirstResponder(nil) + presentDatabaseSwitcher(on: coordinator, target: target) + } + + private func canSwitchContainer( + _ target: ContainerSwitchTarget?, + on coordinator: MainContentCoordinator + ) -> Bool { + let type = coordinator.connection.type + guard MainWindowToolbar.hasLiveSession(coordinator.toolbarState.connectionState) else { return false } + guard PluginManager.shared.connectionMode(for: type) != .fileBased else { return false } + guard let target else { return PluginManager.shared.supportsContainerSwitching(for: type) } + return PluginManager.shared.switchableContainers(for: type).contains(target) + } + + func openQuickSwitcher() { + coordinator?.showQuickSwitcher() + } + + func showColumnJump() { + guard canJumpToColumn else { return } + coordinator?.showColumnJump() + } + + /// The window presents this one. It is a window command wherever it is invoked from, and + /// keeping a copy of the presentation here would give one window two owners for one popover. + func openConnectionSwitcher() { + coordinator?.splitViewController?.openConnectionSwitcher() + } + + func dismissScopeSwitcher() { + coordinator?.switcherPresenter?.dismiss() + } + + /// Anchored to the Database subitem, which is the capsule the user pressed. The group is two + /// capsules wide, so anchoring to it points the chooser at the seam between them; the presenter + /// falls back to the group by itself once AppKit clips it into the overflow menu. + private func presentDatabaseSwitcher(on coordinator: MainContentCoordinator, target: ContainerSwitchTarget?) { + coordinator.switcherPresenter?.present( + from: coordinator.contentWindow, + anchoredTo: MainWindowToolbar.database, + subject: .container(target), + contentSize: DatabaseSwitcherPopover.contentSize + ) { dismiss in + DatabaseSwitcherPopoverHost(coordinator: coordinator, target: target, dismiss: dismiss) + } + } +} diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index a8401ddd37..7949ef70e6 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -1388,71 +1388,6 @@ final class MainContentCommandActions: ObservableObject { coordinator.closeResultSet(id: activeId) } - // MARK: - Database Operations (Group A — Called Directly) - - func openDatabaseSwitcher() { - openScopeSwitcher(nil) - } - - /// The one way into the container chooser, for either scope. It used to have two, and the - /// second skipped the session gate the first applies: the centred toolbar chip opened the - /// chooser over a session the health monitor had given up on, while the button 200pt away and - /// the menu command were both correctly disabled. A chooser with one entry point cannot drift - /// from itself. - /// - /// `nil` means the engine's primary container, which is what a command with no scope named can - /// mean. - func openScopeSwitcher(_ target: ContainerSwitchTarget?) { - guard let coordinator, canSwitchContainer(target, on: coordinator) else { return } - /// Clearing first responder is what lets the popover's search field take focus. - coordinator.contentWindow?.makeFirstResponder(nil) - presentDatabaseSwitcher(on: coordinator, target: target) - } - - private func canSwitchContainer( - _ target: ContainerSwitchTarget?, - on coordinator: MainContentCoordinator - ) -> Bool { - let type = coordinator.connection.type - guard MainWindowToolbar.hasLiveSession(coordinator.toolbarState.connectionState) else { return false } - guard PluginManager.shared.connectionMode(for: type) != .fileBased else { return false } - guard let target else { return PluginManager.shared.supportsContainerSwitching(for: type) } - return PluginManager.shared.switchableContainers(for: type).contains(target) - } - - func openQuickSwitcher() { - coordinator?.showQuickSwitcher() - } - - func showColumnJump() { - guard canJumpToColumn else { return } - coordinator?.showColumnJump() - } - - /// The window presents this one. It is a window command wherever it is invoked from, and - /// keeping a copy of the presentation here would give one window two owners for one popover. - func openConnectionSwitcher() { - coordinator?.splitViewController?.openConnectionSwitcher() - } - - func dismissScopeSwitcher() { - coordinator?.switcherPresenter?.dismiss() - } - - /// Anchored to the Database subitem, which is the capsule the user pressed. The group is two - /// capsules wide, so anchoring to it points the chooser at the seam between them; the presenter - /// falls back to the group by itself once AppKit clips it into the overflow menu. - private func presentDatabaseSwitcher(on coordinator: MainContentCoordinator, target: ContainerSwitchTarget?) { - coordinator.switcherPresenter?.present( - from: coordinator.contentWindow, - anchoredTo: MainWindowToolbar.database, - subject: .container(target), - contentSize: DatabaseSwitcherPopover.contentSize - ) { dismiss in - DatabaseSwitcherPopoverHost(coordinator: coordinator, target: target, dismiss: dismiss) - } - } - // MARK: - Group B Broadcast Subscribers // MARK: Data Broadcasts diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index b6bb44c19a..da8f83295f 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -232,10 +232,15 @@ final class MainContentCoordinator: ObservableObject { /// AppKit object reached through observation-ignored hops, so it cannot invalidate a view. @Published var gridDisplayRevision: Int = 0 - /// Bumped when an inspector edit rewrites the selected row's values, so the inspector's JSON + /// Sent when an inspector edit rewrites the selected row's values, so the inspector's JSON /// rendering re-reads the row. Apart from `gridDisplayRevision`, which drives a full rebuild of /// the field list and takes first responder out of whatever is being typed into. - var inspectorRowContentRevision: Int = 0 + /// + /// An event rather than a published counter. The counter it replaces was not published, so the + /// `onChange` that read it never fired and the JSON rendering went stale. Publishing it would + /// have redrawn every view that observes this coordinator on each keystroke a detached value + /// window commits, and only one of them wants to know. + let inspectorRowContentChanged = PassthroughSubject() /// dispatch insertRows/removeRows directly to the NSTableView via DataGridViewDelegate. weak var dataTabDelegate: DataTabGridDelegate? diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 0a760b9081..3815a572ff 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -346,7 +346,7 @@ struct MainContentView: View { /// A value window detached from a field goes on writing while the JSON rendering is the /// one on screen, and it moves nothing the trigger above watches. Debounced, because it /// commits per keystroke and rebuilding the JSON tree cancels the reader's fetches. - .onChange(of: coordinator.inspectorRowContentRevision) { _ in + .onReceive(coordinator.inspectorRowContentChanged) { _ in scheduleInspectorContextRefresh() } .onAppear { diff --git a/TablePro/Views/ObjectSource/EnumLabelListView.swift b/TablePro/Views/ObjectSource/EnumLabelListView.swift index 59408dd14f..fd441c4f51 100644 --- a/TablePro/Views/ObjectSource/EnumLabelListView.swift +++ b/TablePro/Views/ObjectSource/EnumLabelListView.swift @@ -13,6 +13,7 @@ import SwiftUI /// Each edit is one statement that runs when the field commits, because `ALTER TYPE … ADD VALUE` /// cannot be batched into a transaction on every server that supports it. struct EnumLabelListView: View { + @ObservedObject private var themeEngine = ThemeEngine.shared let labels: [String] let canEdit: Bool let canRename: Bool @@ -139,7 +140,7 @@ struct EnumLabelListView: View { case .draft: TextField(String(localized: "Label"), text: $draftText) .textFieldStyle(.roundedBorder) - .font(ThemeEngine.shared.valueFontSwiftUI) + .font(themeEngine.valueFontSwiftUI) .focused($isDraftFocused) .disabled(isApplying) .onSubmit { commitDraft() } @@ -147,7 +148,7 @@ struct EnumLabelListView: View { .onAppear { isDraftFocused = true } case .label(let label): Text(label) - .font(ThemeEngine.shared.valueFontSwiftUI) + .font(themeEngine.valueFontSwiftUI) .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) .onTapGesture { diff --git a/TablePro/Views/ObjectSource/ObjectSourceTabView.swift b/TablePro/Views/ObjectSource/ObjectSourceTabView.swift index 22a130d07c..3629f898d6 100644 --- a/TablePro/Views/ObjectSource/ObjectSourceTabView.swift +++ b/TablePro/Views/ObjectSource/ObjectSourceTabView.swift @@ -120,6 +120,7 @@ final class ObjectSourceLoader: ObservableObject { } struct ObjectSourceTabView: View { + @ObservedObject private var databaseManager = DatabaseManager.shared let connectionId: UUID let databaseType: DatabaseType let objectRef: DatabaseObjectRef @@ -153,7 +154,7 @@ struct ObjectSourceTabView: View { /// statement. Every other object stays read-only here and is edited in a query tab. private var enumEditor: EnumLabelEditor? { guard objectRef.kind == .userType, objectRef.typeKind == .enumeration, - let connection = DatabaseManager.shared.session(for: connectionId)?.connection + let connection = databaseManager.session(for: connectionId)?.connection else { return nil } return EnumLabelEditor(connection: connection, objectRef: objectRef) } diff --git a/TablePro/Views/Results/ArrayJsonElementEditor.swift b/TablePro/Views/Results/ArrayJsonElementEditor.swift index 2865f776ea..e69e5ea9fc 100644 --- a/TablePro/Views/Results/ArrayJsonElementEditor.swift +++ b/TablePro/Views/Results/ArrayJsonElementEditor.swift @@ -16,6 +16,7 @@ import TableProPluginKit /// collapsing into one: `to_jsonb()` writes a SQL NULL element and a JSON null element both as /// `null`, and that distinction is the point of editing the column in place. internal struct ArrayJsonElementEditor: View { + @ObservedObject private var themeEngine = ThemeEngine.shared @Binding internal var rows: [ArrayEditorRow] @Binding internal var selection: UUID? internal let isReadOnly: Bool @@ -62,7 +63,7 @@ internal struct ArrayJsonElementEditor: View { .frame(width: 20, alignment: .trailing) if let summary = display.summary { Text(summary) - .font(ThemeEngine.shared.valueFontSwiftUI) + .font(themeEngine.valueFontSwiftUI) .lineLimit(1) .truncationMode(.tail) } else { @@ -120,7 +121,7 @@ internal struct ArrayJsonElementEditor: View { private var nullPlaceholder: some View { Text("NULL") .italic() - .font(ThemeEngine.shared.valueFontSwiftUI) + .font(themeEngine.valueFontSwiftUI) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, maxHeight: .infinity) } diff --git a/TablePro/Views/Results/ArrayValueEditorView.swift b/TablePro/Views/Results/ArrayValueEditorView.swift index 5ad92ea87d..0e249ec03a 100644 --- a/TablePro/Views/Results/ArrayValueEditorView.swift +++ b/TablePro/Views/Results/ArrayValueEditorView.swift @@ -9,6 +9,7 @@ import SwiftUI import TableProPluginKit struct ArrayValueEditorView: View { + @ObservedObject private var themeEngine = ThemeEngine.shared let allowedValues: [String] let isNullable: Bool let delimiter: Character @@ -154,7 +155,7 @@ struct ArrayValueEditorView: View { ) ) .textFieldStyle(.roundedBorder) - .font(ThemeEngine.shared.valueFontSwiftUI) + .font(themeEngine.valueFontSwiftUI) .disabled(element.wrappedValue == .null) Toggle("NULL", isOn: Binding( get: { element.wrappedValue == .null }, @@ -177,7 +178,7 @@ struct ArrayValueEditorView: View { ) { ForEach(Array(options.enumerated()), id: \.offset) { optionIndex, option in Text(option) - .font(ThemeEngine.shared.valueFontSwiftUI) + .font(themeEngine.valueFontSwiftUI) .tag(optionIndex) } Text("NULL").italic().tag(options.count) @@ -224,7 +225,7 @@ struct ArrayValueEditorView: View { private var rawTextEditor: some View { VStack(alignment: .leading, spacing: 4) { TextEditor(text: $rawText) - .font(ThemeEngine.shared.valueFontSwiftUI) + .font(themeEngine.valueFontSwiftUI) .frame(minHeight: 90) if PostgresArrayLiteralCodec.parse(rawText, delimiter: delimiter) == nil { Label { diff --git a/TablePro/Views/Results/CellImageWindowController.swift b/TablePro/Views/Results/CellImageWindowController.swift index fcb5d8f05b..ceade23f35 100644 --- a/TablePro/Views/Results/CellImageWindowController.swift +++ b/TablePro/Views/Results/CellImageWindowController.swift @@ -40,6 +40,7 @@ internal final class CellImageWindowController: ValueViewerWindowController { } private struct CellImageWindowContent: View { + @ObservedObject private var themeEngine = ThemeEngine.shared let data: Data let format: CellImageFormat let sourceKind: CellImageSourceKind @@ -62,7 +63,7 @@ private struct CellImageWindowContent: View { TextValueEditor( text: .constant(String(bytes: data, encoding: .utf8) ?? ""), isEditable: false, - font: ThemeEngine.shared.valueFont + font: themeEngine.valueFont ) case .hex: HexEditorBody( diff --git a/TablePro/Views/Results/ColumnVisibilityPopover.swift b/TablePro/Views/Results/ColumnVisibilityPopover.swift index 38a9c0a8a7..01b709d12d 100644 --- a/TablePro/Views/Results/ColumnVisibilityPopover.swift +++ b/TablePro/Views/Results/ColumnVisibilityPopover.swift @@ -6,6 +6,7 @@ import SwiftUI struct ColumnVisibilityPopover: View { + @ObservedObject private var settingsManager = AppSettingsManager.shared let columns: [GridColumnEntry] let hiddenColumns: Set let onToggleColumn: (String) -> Void @@ -53,7 +54,7 @@ struct ColumnVisibilityPopover: View { Button("Jump to Column…") { onJumpToColumn(searchText) } .buttonStyle(.link) .controlSize(.small) - .help(AppSettingsManager.shared.keyboard.shortcutHint( + .help(settingsManager.keyboard.shortcutHint( String(localized: "Scroll to a column and put the cell cursor in it"), for: .jumpToColumn )) diff --git a/TablePro/Views/Results/ExecutionIndicatorView.swift b/TablePro/Views/Results/ExecutionIndicatorView.swift index b9c68b6054..84970bcbc6 100644 --- a/TablePro/Views/Results/ExecutionIndicatorView.swift +++ b/TablePro/Views/Results/ExecutionIndicatorView.swift @@ -12,6 +12,7 @@ import TableProPluginKit /// where AppKit dropped it whole as soon as the window narrowed. Every comparable client puts this /// in a bottom bar, and so does the rest of what this bar already reports. struct ExecutionIndicatorView: View { + @ObservedObject private var settingsManager = AppSettingsManager.shared let isExecuting: Bool let lastTiming: PluginQueryTiming? var onCancel: (() -> Void)? @@ -41,7 +42,7 @@ struct ExecutionIndicatorView: View { /// Resolved from the user's own binding rather than written into the string. A hint naming a /// key nobody bound is the same defect as a toolbar tooltip that outlived a rebind (#2185). private var cancelHint: String { - AppSettingsManager.shared.keyboard.shortcutHint(String(localized: "Cancel Query"), for: .cancelQuery) + settingsManager.keyboard.shortcutHint(String(localized: "Cancel Query"), for: .cancelQuery) } var body: some View { diff --git a/TablePro/Views/Results/ForeignKeyPickerView.swift b/TablePro/Views/Results/ForeignKeyPickerView.swift index d277b58924..ce2a34d449 100644 --- a/TablePro/Views/Results/ForeignKeyPickerView.swift +++ b/TablePro/Views/Results/ForeignKeyPickerView.swift @@ -10,6 +10,7 @@ import SwiftUI import TableProPluginKit struct ForeignKeyPickerView: View { + @ObservedObject private var themeEngine = ThemeEngine.shared let scope: DatabaseScope let databaseType: DatabaseType let fkInfo: ForeignKeyInfo @@ -175,11 +176,11 @@ struct ForeignKeyPickerView: View { .foregroundStyle(.secondary) .opacity(row.key == currentValue ? 1 : 0) Text(row.key) - .font(ThemeEngine.shared.valueFontSwiftUI) + .font(themeEngine.valueFontSwiftUI) .lineLimit(1) if let label = row.label, !label.isEmpty { Text(label) - .font(ThemeEngine.shared.valueFontSwiftUI) + .font(themeEngine.valueFontSwiftUI) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.tail) diff --git a/TablePro/Views/Results/ForeignKeyPreviewView.swift b/TablePro/Views/Results/ForeignKeyPreviewView.swift index 5de052a0be..d836ab5156 100644 --- a/TablePro/Views/Results/ForeignKeyPreviewView.swift +++ b/TablePro/Views/Results/ForeignKeyPreviewView.swift @@ -27,6 +27,7 @@ private struct FKPreviewTaskKey: Equatable { } struct ForeignKeyPreviewView: View { + @ObservedObject private var themeEngine = ThemeEngine.shared @ObservedObject var model: FKPreviewModel let scope: DatabaseScope let databaseType: DatabaseType @@ -127,13 +128,13 @@ struct ForeignKeyPreviewView: View { if let val = value { Text(val) - .font(ThemeEngine.shared.valueFontSwiftUI) + .font(themeEngine.valueFontSwiftUI) .foregroundStyle(.primary) .lineLimit(3) .textSelection(.enabled) } else { Text("NULL") - .font(ThemeEngine.shared.valueFontSwiftUI) + .font(themeEngine.valueFontSwiftUI) .foregroundStyle(.tertiary) .italic() } diff --git a/TablePro/Views/Results/HexEditorContentView.swift b/TablePro/Views/Results/HexEditorContentView.swift index 8b3e859bb1..6638db272f 100644 --- a/TablePro/Views/Results/HexEditorContentView.swift +++ b/TablePro/Views/Results/HexEditorContentView.swift @@ -26,6 +26,7 @@ internal enum HexEditorMetrics { } struct HexEditorBody: View { + @ObservedObject private var themeEngine = ThemeEngine.shared let initialValue: String? let isEditable: Bool let onCommit: (String) -> Void @@ -78,7 +79,7 @@ struct HexEditorBody: View { var body: some View { VStack(spacing: 0) { - HexDumpDisplayView(text: hexDumpText, font: ThemeEngine.shared.valueFont) + HexDumpDisplayView(text: hexDumpText, font: themeEngine.valueFont) if isEditable { Divider() @@ -88,7 +89,7 @@ struct HexEditorBody: View { .font(.caption) .foregroundStyle(.secondary) - HexInputTextView(text: $editableHex, font: ThemeEngine.shared.valueFont) + HexInputTextView(text: $editableHex, font: themeEngine.valueFont) .frame(height: 80) HStack(spacing: 4) { diff --git a/TablePro/Views/Results/JSONCodeEditor.swift b/TablePro/Views/Results/JSONCodeEditor.swift index df1d9a0585..eed5ac7d3d 100644 --- a/TablePro/Views/Results/JSONCodeEditor.swift +++ b/TablePro/Views/Results/JSONCodeEditor.swift @@ -12,6 +12,8 @@ import TableProEditorKit import TableProGrammars internal struct JSONCodeEditor: View { + @ObservedObject private var settingsManager = AppSettingsManager.shared + @ObservedObject private var themeEngine = ThemeEngine.shared @Binding var text: String let isEditable: Bool @@ -36,7 +38,7 @@ internal struct JSONCodeEditor: View { .onChange(of: colorScheme) { _ in rebuildConfiguration() } - .onChange(of: AppSettingsManager.shared.editor) { _ in + .onChange(of: settingsManager.editor) { _ in rebuildConfiguration() } .onReceive(AppEvents.shared.accessibilityTextSizeChanged) { _ in diff --git a/TablePro/Views/Results/JSONTreeView.swift b/TablePro/Views/Results/JSONTreeView.swift index 049e99f901..e85c055884 100644 --- a/TablePro/Views/Results/JSONTreeView.swift +++ b/TablePro/Views/Results/JSONTreeView.swift @@ -23,20 +23,21 @@ internal struct JSONTreeView: View { // MARK: - Row View private struct JSONTreeRowView: View { + @ObservedObject private var themeEngine = ThemeEngine.shared let node: JSONTreeNode var body: some View { HStack(spacing: 4) { if let key = node.key { Text(key) - .font(ThemeEngine.shared.valueFontEmphasizedSwiftUI) + .font(themeEngine.valueFontEmphasizedSwiftUI) .foregroundStyle(.blue) .lineLimit(1) Text(":") .foregroundStyle(.secondary) } Text(node.displayValue) - .font(ThemeEngine.shared.valueFontSwiftUI) + .font(themeEngine.valueFontSwiftUI) .foregroundStyle(Color(nsColor: node.valueType.color)) .lineLimit(1) Spacer(minLength: 4) diff --git a/TablePro/Views/Results/PhpTreeView.swift b/TablePro/Views/Results/PhpTreeView.swift index 7722b604a0..06c6f74656 100644 --- a/TablePro/Views/Results/PhpTreeView.swift +++ b/TablePro/Views/Results/PhpTreeView.swift @@ -23,13 +23,14 @@ internal struct PhpTreeView: View { // MARK: - Row View private struct PhpTreeRowView: View { + @ObservedObject private var themeEngine = ThemeEngine.shared let node: PhpTreeNode var body: some View { HStack(spacing: 4) { if let key = node.key { Text(key) - .font(ThemeEngine.shared.valueFontEmphasizedSwiftUI) + .font(themeEngine.valueFontEmphasizedSwiftUI) .foregroundStyle(.blue) .lineLimit(1) if let badge = node.visibilityBadge { @@ -41,7 +42,7 @@ private struct PhpTreeRowView: View { .foregroundStyle(.secondary) } Text(node.displayValue) - .font(ThemeEngine.shared.valueFontSwiftUI) + .font(themeEngine.valueFontSwiftUI) .foregroundStyle(Color(nsColor: node.nodeType.color)) .lineLimit(1) Spacer(minLength: 4) diff --git a/TablePro/Views/Results/PhpViewerView.swift b/TablePro/Views/Results/PhpViewerView.swift index 01760a35a3..2246ae9170 100644 --- a/TablePro/Views/Results/PhpViewerView.swift +++ b/TablePro/Views/Results/PhpViewerView.swift @@ -31,6 +31,7 @@ internal enum PhpParseResult: Equatable { @MainActor internal struct PhpViewerView: View { + @ObservedObject private var themeEngine = ThemeEngine.shared let rawValue: String var onDismiss: (() -> Void)? var onPopOut: ((String) -> Void)? @@ -128,7 +129,7 @@ internal struct PhpViewerView: View { private var rawBody: some View { ScrollView { Text(rawValue) - .font(ThemeEngine.shared.valueFontSwiftUI) + .font(themeEngine.valueFontSwiftUI) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) .padding(10) diff --git a/TablePro/Views/Results/ResultStatusBar.swift b/TablePro/Views/Results/ResultStatusBar.swift index e9501f0403..e05b00085d 100644 --- a/TablePro/Views/Results/ResultStatusBar.swift +++ b/TablePro/Views/Results/ResultStatusBar.swift @@ -31,6 +31,7 @@ import SwiftUI /// tree cannot present, so giving up the Highlight Rules button would have made /// `View > Highlight Rules…` do nothing. See `StatusBarTier` for what a tier may give up. struct ResultStatusBar: View { + @ObservedObject private var settingsManager = AppSettingsManager.shared let model: ResultStatusModel let snapshot: StatusBarSnapshot let filterState: TabFilterState @@ -367,7 +368,7 @@ struct ResultStatusBar: View { .statusBarLabelStyle(showsTitle: presentation.showsControlTitles) .toggleStyle(.button) .controlSize(.small) - .help(AppSettingsManager.shared.keyboard.shortcutHint(String(localized: "Filters"), for: .toggleFilters)) + .help(settingsManager.keyboard.shortcutHint(String(localized: "Filters"), for: .toggleFilters)) .accessibilityLabel(String(localized: "Filters")) .accessibilityValue(filtersAccessibilityValue) .accessibilityAddTraits(filterState.isVisible ? .isSelected : []) diff --git a/TablePro/Views/Results/SetPopoverContentView.swift b/TablePro/Views/Results/SetPopoverContentView.swift index dac9c25ad2..3f5d85d105 100644 --- a/TablePro/Views/Results/SetPopoverContentView.swift +++ b/TablePro/Views/Results/SetPopoverContentView.swift @@ -8,6 +8,7 @@ import SwiftUI struct SetPopoverContentView: View { + @ObservedObject private var themeEngine = ThemeEngine.shared let allowedValues: [String] let initialSelections: [String: Bool] let onCommit: (String?) -> Void @@ -41,7 +42,7 @@ struct SetPopoverContentView: View { ) ) .toggleStyle(.checkbox) - .font(ThemeEngine.shared.valueFontSwiftUI) + .font(themeEngine.valueFontSwiftUI) } } .padding(12) diff --git a/TablePro/Views/Results/SvgViewerContentView.swift b/TablePro/Views/Results/SvgViewerContentView.swift index a67a688a97..90d721896d 100644 --- a/TablePro/Views/Results/SvgViewerContentView.swift +++ b/TablePro/Views/Results/SvgViewerContentView.swift @@ -8,6 +8,7 @@ import SwiftUI /// The popover a text cell holding SVG markup opens: the drawing, with the markup one segment away /// and still editable where the cell is. internal struct SvgViewerContentView: View { + @ObservedObject private var themeEngine = ThemeEngine.shared let initialValue: String let isEditable: Bool let onDismiss: () -> Void @@ -42,7 +43,7 @@ internal struct SvgViewerContentView: View { TextValueEditor( text: $text, isEditable: isEditable, - font: ThemeEngine.shared.valueFont + font: themeEngine.valueFont ) } diff --git a/TablePro/Views/Results/TextViewerWindowController.swift b/TablePro/Views/Results/TextViewerWindowController.swift index e332ce0e01..6b426039b2 100644 --- a/TablePro/Views/Results/TextViewerWindowController.swift +++ b/TablePro/Views/Results/TextViewerWindowController.swift @@ -40,6 +40,7 @@ internal final class TextViewerWindowController: ValueViewerWindowController { // MARK: - Window Content private struct TextViewerWindowContent: View { + @ObservedObject private var themeEngine = ThemeEngine.shared let isEditable: Bool let onCommit: ((String) -> Void)? let onDismiss: (() -> Void)? @@ -62,7 +63,7 @@ private struct TextViewerWindowContent: View { TextValueEditor( text: $text, isEditable: isEditable, - font: ThemeEngine.shared.valueFont, + font: themeEngine.valueFont, textContainerInset: NSSize(width: 8, height: 10) ) .onChange(of: text) { _ in diff --git a/TablePro/Views/Rewind/RewindReviewSheet.swift b/TablePro/Views/Rewind/RewindReviewSheet.swift index 0902460b0a..980c24e70f 100644 --- a/TablePro/Views/Rewind/RewindReviewSheet.swift +++ b/TablePro/Views/Rewind/RewindReviewSheet.swift @@ -12,6 +12,7 @@ import SwiftUI import TableProPluginKit struct RewindReviewSheet: View { + @ObservedObject private var themeEngine = ThemeEngine.shared @Environment(\.dismiss) private var dismiss let plan: RewindPlan @@ -79,7 +80,7 @@ struct RewindReviewSheet: View { Table(plan.rows) { TableColumn(String(localized: "Row")) { row in Text(row.keyDescription) - .font(ThemeEngine.shared.valueFontSwiftUI) + .font(themeEngine.valueFontSwiftUI) .lineLimit(1) } TableColumn(String(localized: "Action")) { row in @@ -104,7 +105,7 @@ struct RewindReviewSheet: View { VStack(alignment: .leading, spacing: 4) { ForEach(Array(displayStatements.enumerated()), id: \.offset) { _, statement in Text(statement) - .font(Font(ThemeEngine.shared.editorFonts.font)) + .font(Font(themeEngine.editorFonts.font)) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) } diff --git a/TablePro/Views/RowInspector/FieldEditors/ArrayFieldEditorView.swift b/TablePro/Views/RowInspector/FieldEditors/ArrayFieldEditorView.swift index 193d3b9860..5256829389 100644 --- a/TablePro/Views/RowInspector/FieldEditors/ArrayFieldEditorView.swift +++ b/TablePro/Views/RowInspector/FieldEditors/ArrayFieldEditorView.swift @@ -14,6 +14,7 @@ import TableProPluginKit /// DEFAULT would have nowhere to live, since the field's binding carries a `String` and neither is /// one. internal struct ArrayFieldEditorView: View { + @ObservedObject private var themeEngine = ThemeEngine.shared internal let context: FieldEditorContext internal let elementEditor: ArrayElementEditor internal let allowedValues: [String] @@ -40,7 +41,7 @@ internal struct ArrayFieldEditorView: View { } } label: { Text(displayLabel) - .font(ThemeEngine.shared.valueFontSwiftUI) + .font(themeEngine.valueFontSwiftUI) .foregroundStyle(context.valueState.placeholder == nil ? .primary : .secondary) .lineLimit(1) .truncationMode(.tail) diff --git a/TablePro/Views/RowInspector/FieldEditors/BlobHexEditorView.swift b/TablePro/Views/RowInspector/FieldEditors/BlobHexEditorView.swift index d0371b87cc..eb50699431 100644 --- a/TablePro/Views/RowInspector/FieldEditors/BlobHexEditorView.swift +++ b/TablePro/Views/RowInspector/FieldEditors/BlobHexEditorView.swift @@ -6,6 +6,7 @@ import SwiftUI internal struct BlobHexEditorView: View { + @ObservedObject private var themeEngine = ThemeEngine.shared let context: FieldEditorContext @FocusState private var isFocused: Bool @@ -43,7 +44,7 @@ internal struct BlobHexEditorView: View { private var readOnlyHexView: some View { ScrollView([.horizontal, .vertical]) { Text(BlobFormattingService.shared.format(context.value.wrappedValue, for: .detail) ?? "") - .font(ThemeEngine.shared.valueFontSwiftUI) + .font(themeEngine.valueFontSwiftUI) .textSelection(.enabled) .fixedSize() .frame(maxWidth: .infinity, alignment: .topLeading) @@ -61,7 +62,7 @@ internal struct BlobHexEditorView: View { readOnlyHexView } else { TextField("Hex bytes", text: $hexEditText, axis: .vertical) - .font(ThemeEngine.shared.valueFontSwiftUI) + .font(themeEngine.valueFontSwiftUI) .textFieldStyle(.roundedBorder) .lineLimit(3...8) .autocorrectionDisabled(true) diff --git a/TablePro/Views/RowInspector/FieldEditors/FieldEditorContent.swift b/TablePro/Views/RowInspector/FieldEditors/FieldEditorContent.swift index df5707a092..a68a9cec90 100644 --- a/TablePro/Views/RowInspector/FieldEditors/FieldEditorContent.swift +++ b/TablePro/Views/RowInspector/FieldEditors/FieldEditorContent.swift @@ -105,12 +105,13 @@ internal struct FieldEditorContent: View { /// What a field shows in place of its editor once the user has asked for NULL or DEFAULT. internal struct PendingStatePill: View { + @ObservedObject private var themeEngine = ThemeEngine.shared internal let state: FieldValueState internal var minHeight: CGFloat? var body: some View { Text(state.placeholder ?? "") - .font(ThemeEngine.shared.valueFontSwiftUI) + .font(themeEngine.valueFontSwiftUI) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, minHeight: minHeight, alignment: .topLeading) .padding(.horizontal, 6) diff --git a/TablePro/Views/RowInspector/FieldEditors/ImageFieldView.swift b/TablePro/Views/RowInspector/FieldEditors/ImageFieldView.swift index 369a9da12c..c3392981ad 100644 --- a/TablePro/Views/RowInspector/FieldEditors/ImageFieldView.swift +++ b/TablePro/Views/RowInspector/FieldEditors/ImageFieldView.swift @@ -9,6 +9,7 @@ import SwiftUI /// segment away, so a binary field is still editable as hex and SVG markup is still editable as /// text. internal struct ImageFieldView: View { + @ObservedObject private var themeEngine = ThemeEngine.shared let context: FieldEditorContext let format: CellImageFormat @@ -54,7 +55,7 @@ internal struct ImageFieldView: View { TextValueEditor( text: context.value, isEditable: !context.isReadOnly, - font: ThemeEngine.shared.valueFont + font: themeEngine.valueFont ) case .hex: BlobHexEditorView(context: context) diff --git a/TablePro/Views/RowInspector/FieldEditors/MultiLineEditorView.swift b/TablePro/Views/RowInspector/FieldEditors/MultiLineEditorView.swift index e2b38f2a64..aa0b9546d8 100644 --- a/TablePro/Views/RowInspector/FieldEditors/MultiLineEditorView.swift +++ b/TablePro/Views/RowInspector/FieldEditors/MultiLineEditorView.swift @@ -6,6 +6,7 @@ import SwiftUI internal struct MultiLineEditorView: View { + @ObservedObject private var themeEngine = ThemeEngine.shared let context: FieldEditorContext var onPopOut: ((String) -> Void)? var isExpanded = false @@ -22,7 +23,7 @@ internal struct MultiLineEditorView: View { TextValueEditor( text: context.value, isEditable: !context.isReadOnly, - font: ThemeEngine.shared.valueFont, + font: themeEngine.valueFont, movesFocusOnTab: true ) .clipShape(RoundedRectangle(cornerRadius: 5)) diff --git a/TablePro/Views/RowInspector/FieldEditors/SetPickerView.swift b/TablePro/Views/RowInspector/FieldEditors/SetPickerView.swift index 9ffbc95cfb..9c7e8df6e4 100644 --- a/TablePro/Views/RowInspector/FieldEditors/SetPickerView.swift +++ b/TablePro/Views/RowInspector/FieldEditors/SetPickerView.swift @@ -6,6 +6,7 @@ import SwiftUI internal struct SetPickerView: View { + @ObservedObject private var themeEngine = ThemeEngine.shared internal let context: FieldEditorContext internal let values: [String] internal var onSetNull: (() -> Void)? @@ -34,7 +35,7 @@ internal struct SetPickerView: View { /// a second one in the label does not land beside it: measured, the label's chevron /// renders at the *leading* edge, so the field read `⌄ a,b ⌄`. Text(displayLabel) - .font(ThemeEngine.shared.valueFontSwiftUI) + .font(themeEngine.valueFontSwiftUI) .foregroundStyle(context.valueState.placeholder == nil ? .primary : .secondary) .lineLimit(1) .truncationMode(.tail) diff --git a/TablePro/Views/RowInspector/InspectorFieldRow.swift b/TablePro/Views/RowInspector/InspectorFieldRow.swift index 329c71b9b2..9b6259946a 100644 --- a/TablePro/Views/RowInspector/InspectorFieldRow.swift +++ b/TablePro/Views/RowInspector/InspectorFieldRow.swift @@ -13,6 +13,7 @@ import SwiftUI /// and invisible to anyone who does not happen to hover. It is drawn unconditionally now, which is /// what Postico does and what a control that is the only way to reach a command has to do. internal struct InspectorFieldRow: View { + @ObservedObject private var themeEngine = ThemeEngine.shared internal let context: FieldEditorContext internal let layout: InspectorFieldLayout internal let kind: FieldEditorKind diff --git a/TablePro/Views/RowInspector/JSON/JSONNodeRowView.swift b/TablePro/Views/RowInspector/JSON/JSONNodeRowView.swift index d7f885f7c3..af9f0065c5 100644 --- a/TablePro/Views/RowInspector/JSON/JSONNodeRowView.swift +++ b/TablePro/Views/RowInspector/JSON/JSONNodeRowView.swift @@ -8,6 +8,7 @@ import SwiftUI struct JSONNodeRowView: View { + @ObservedObject private var themeEngine = ThemeEngine.shared let row: JSONDisplayRow let colors: JSONRowColors let onToggle: () -> Void @@ -16,7 +17,7 @@ struct JSONNodeRowView: View { private static let indentWidth: CGFloat = 14 private static let controlWidth: CGFloat = 14 - private var valueFont: Font { ThemeEngine.shared.valueFontSwiftUI } + private var valueFont: Font { themeEngine.valueFontSwiftUI } var body: some View { HStack(alignment: .firstTextBaseline, spacing: 0) { diff --git a/TablePro/Views/RowInspector/JSON/JSONRowInspectorView.swift b/TablePro/Views/RowInspector/JSON/JSONRowInspectorView.swift index d61878cf7a..2ee90e8f06 100644 --- a/TablePro/Views/RowInspector/JSON/JSONRowInspectorView.swift +++ b/TablePro/Views/RowInspector/JSON/JSONRowInspectorView.swift @@ -9,6 +9,7 @@ import SwiftUI struct JSONRowInspectorView: View { + @ObservedObject private var themeEngine = ThemeEngine.shared @ObservedObject var viewModel: JSONRowInspectorViewModel let snapshot: JSONRowSnapshot? @@ -128,7 +129,7 @@ struct JSONRowInspectorView: View { .padding(.vertical, 6) .frame(maxWidth: .infinity, alignment: .topLeading) } - .background(Color(nsColor: ThemeEngine.shared.colors.editor.background)) + .background(Color(nsColor: themeEngine.colors.editor.background)) .accessibilityLabel(String(localized: "Row as JSON")) } } diff --git a/TablePro/Views/RowInspector/TableInfoView.swift b/TablePro/Views/RowInspector/TableInfoView.swift index 626de3bc5a..8ae0696d80 100644 --- a/TablePro/Views/RowInspector/TableInfoView.swift +++ b/TablePro/Views/RowInspector/TableInfoView.swift @@ -19,12 +19,13 @@ import SwiftUI /// the pane, and a value that still does not fit wraps rather than eliding. Wrapping is what /// Finder's Get Info does with a long value at this width, measured. internal struct TableInfoView: View { + @ObservedObject private var settingsManager = AppSettingsManager.shared internal let metadata: TableMetadata var body: some View { ScrollView { VStack(alignment: .leading, spacing: 14) { - if AppSettingsManager.shared.general.showObjectComments, + if settingsManager.general.showObjectComments, let comment = metadata.comment, !comment.isEmpty { section(String(localized: "Comment")) { Text(comment) diff --git a/TablePro/Views/Settings/GeneralSettingsView.swift b/TablePro/Views/Settings/GeneralSettingsView.swift index 3a2455a10d..ec388d5202 100644 --- a/TablePro/Views/Settings/GeneralSettingsView.swift +++ b/TablePro/Views/Settings/GeneralSettingsView.swift @@ -9,7 +9,10 @@ import SwiftUI struct GeneralSettingsView: View { @Binding var settings: GeneralSettings @Binding var tabSettings: TabSettings - var updater: SoftwareUpdater + /// Observed, because this view reads `canCheckForUpdates`, `lastUpdateCheckDate` and the button + /// title off it. Held as a plain property, Last checked and Check for Updates… kept whatever they + /// said when Settings opened, however many checks ran behind them. + @ObservedObject var updater: SoftwareUpdater var onResetAll: () -> Void @State private var initialLanguage: AppLanguage? diff --git a/TablePro/Views/Settings/LinkedFoldersSection.swift b/TablePro/Views/Settings/LinkedFoldersSection.swift index 1166ec974c..68db362b87 100644 --- a/TablePro/Views/Settings/LinkedFoldersSection.swift +++ b/TablePro/Views/Settings/LinkedFoldersSection.swift @@ -11,10 +11,11 @@ import SwiftUI import TableProImport struct LinkedFoldersSection: View { + @ObservedObject private var licenseManager = LicenseManager.shared @State private var folders: [LinkedFolder] = LinkedFolderStorage.shared.loadFolders() private var isLicensed: Bool { - LicenseManager.shared.isFeatureAvailable(.linkedFolders) + licenseManager.isFeatureAvailable(.linkedFolders) } var body: some View { diff --git a/TablePro/Views/Settings/Sections/DataRewindSection.swift b/TablePro/Views/Settings/Sections/DataRewindSection.swift index b785707411..5834ba98cb 100644 --- a/TablePro/Views/Settings/Sections/DataRewindSection.swift +++ b/TablePro/Views/Settings/Sections/DataRewindSection.swift @@ -6,10 +6,11 @@ import SwiftUI struct DataRewindSection: View { + @ObservedObject private var licenseManager = LicenseManager.shared @Binding var settings: HistorySettings private var isAvailable: Bool { - LicenseManager.shared.isFeatureAvailable(.dataRewind) + licenseManager.isFeatureAvailable(.dataRewind) } var body: some View { diff --git a/TablePro/Views/Settings/Sections/MCPGrantListView.swift b/TablePro/Views/Settings/Sections/MCPGrantListView.swift index 27c267b600..c7dd80d753 100644 --- a/TablePro/Views/Settings/Sections/MCPGrantListView.swift +++ b/TablePro/Views/Settings/Sections/MCPGrantListView.swift @@ -7,6 +7,7 @@ import SwiftUI /// The approvals the user has already given, so a remembered answer stays visible and revocable. struct MCPGrantListView: View { + @ObservedObject private var mcpServerManager = MCPServerManager.shared @State private var grants: [MCPConnectionGrant] = [] @State private var connectionNames: [UUID: String] = [:] @@ -51,7 +52,7 @@ struct MCPGrantListView: View { } private func refresh() async { - grants = await MCPServerManager.shared.connectionGrants() + grants = await mcpServerManager.connectionGrants() .sorted { $0.grantedAt > $1.grantedAt } connectionNames = Dictionary( ConnectionStorage.shared.loadConnections().map { ($0.id, $0.name) }, diff --git a/TablePro/Views/Settings/Sections/SyncSection.swift b/TablePro/Views/Settings/Sections/SyncSection.swift index 43ad7de22a..e1739f1c39 100644 --- a/TablePro/Views/Settings/Sections/SyncSection.swift +++ b/TablePro/Views/Settings/Sections/SyncSection.swift @@ -7,11 +7,12 @@ import SwiftUI import TableProSyncTransport struct SyncSection: View { + @ObservedObject private var licenseManager = LicenseManager.shared @ObservedObject private var settingsManager = AppSettingsManager.shared @ObservedObject private var syncCoordinator = SyncCoordinator.shared private var isProAvailable: Bool { - LicenseManager.shared.isFeatureAvailable(.iCloudSync) + licenseManager.isFeatureAvailable(.iCloudSync) } var body: some View { diff --git a/TablePro/Views/Settings/SyncSettingsView.swift b/TablePro/Views/Settings/SyncSettingsView.swift index cdc7bb1734..06c9e6690e 100644 --- a/TablePro/Views/Settings/SyncSettingsView.swift +++ b/TablePro/Views/Settings/SyncSettingsView.swift @@ -13,6 +13,9 @@ import TableProSyncTransport /// on its own a reason to live beside one. struct SyncSettingsView: View { @ObservedObject private var syncCoordinator = SyncCoordinator.shared + /// Observed for `isValidating`, which disables Check Again while a check runs. Read off the + /// singleton directly, the button never learned that a check had started or finished. + @ObservedObject private var licenseManager = LicenseManager.shared var body: some View { Form { @@ -36,9 +39,9 @@ struct SyncSettingsView: View { ) ) { Button(String(localized: "Check Again")) { - Task { await LicenseManager.shared.revalidate() } + Task { await licenseManager.revalidate() } } - .disabled(LicenseManager.shared.isValidating) + .disabled(licenseManager.isValidating) } default: EmptyView() diff --git a/TablePro/Views/Sidebar/DatabaseTreeRowView.swift b/TablePro/Views/Sidebar/DatabaseTreeRowView.swift index 28ddebc897..28ee07d848 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeRowView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeRowView.swift @@ -46,6 +46,7 @@ struct DatabaseTreeRowContext { } struct DatabaseTreeRowView: View { + @ObservedObject private var settingsManager = AppSettingsManager.shared let node: DatabaseTreeNode let isFavorite: Bool let context: DatabaseTreeRowContext @@ -128,7 +129,7 @@ struct DatabaseTreeRowView: View { private func objectGroupRow(_ kind: SidebarObjectKind) -> some View { Label(context.objectKindTitle(kind), systemImage: kind.iconName) - .sidebarRowIcon(visible: AppSettingsManager.shared.general.showObjectIcons) + .sidebarRowIcon(visible: settingsManager.general.showObjectIcons) .lineLimit(1) } @@ -203,7 +204,7 @@ struct DatabaseTreeRowView: View { .selectionAwareTint(Color.accentColor) .frame(width: 16) } - .sidebarRowIcon(visible: AppSettingsManager.shared.general.showObjectIcons) + .sidebarRowIcon(visible: settingsManager.general.showObjectIcons) .accessibilityElement(children: .combine) .accessibilityLabel( SidebarPartitionRow.accessibilityLabel( @@ -268,7 +269,7 @@ struct DatabaseTreeRowView: View { } icon: { Image(systemName: systemImage) } - .sidebarRowIcon(visible: AppSettingsManager.shared.general.showObjectIcons) + .sidebarRowIcon(visible: settingsManager.general.showObjectIcons) .lineLimit(1) .sidebarRowForeground(isActive: isActive, isSystem: isSystem) } diff --git a/TablePro/Views/Sidebar/DatabaseTreeView.swift b/TablePro/Views/Sidebar/DatabaseTreeView.swift index 2f8ae0b68c..017e55a706 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeView.swift @@ -49,6 +49,7 @@ struct DatabaseTreeUserTypeRef: Identifiable, Equatable { } struct DatabaseTreeView: View { + @ObservedObject private var databaseManager = DatabaseManager.shared @ObservedObject private var treeService = DatabaseTreeMetadataService.shared let connectionId: UUID @@ -78,7 +79,7 @@ struct DatabaseTreeView: View { } private var isConnected: Bool { - DatabaseManager.shared.session(for: connectionId)?.status == .connected + databaseManager.session(for: connectionId)?.status == .connected } private var databases: [DatabaseMetadata] { diff --git a/TablePro/Views/Sidebar/FavoritesTabView.swift b/TablePro/Views/Sidebar/FavoritesTabView.swift index afbca80aec..be36a000b2 100644 --- a/TablePro/Views/Sidebar/FavoritesTabView.swift +++ b/TablePro/Views/Sidebar/FavoritesTabView.swift @@ -2,6 +2,9 @@ import SwiftUI import TableProImport internal struct FavoritesTabView: View { + @ObservedObject private var teamLibrarySync = TeamLibrarySyncCoordinator.shared + @ObservedObject private var licenseManager = LicenseManager.shared + @ObservedObject private var settingsManager = AppSettingsManager.shared @Environment(\.sidebarRowSize) private var systemRowSize @StateObject private var viewModel: FavoritesSidebarViewModel @@ -203,8 +206,8 @@ internal struct FavoritesTabView: View { // MARK: - List private var teamLibraryQueries: [TeamLibraryPullResponse.Query] { - guard LicenseManager.shared.isFeatureAvailable(.teamLibrary) else { return [] } - let all = TeamLibrarySyncCoordinator.shared.library.queries + guard licenseManager.isFeatureAvailable(.teamLibrary) else { return [] } + let all = teamLibrarySync.library.queries guard !searchText.isEmpty else { return all } return all.filter { $0.name.localizedCaseInsensitiveContains(searchText) || $0.query.localizedCaseInsensitiveContains(searchText) @@ -288,10 +291,10 @@ internal struct FavoritesTabView: View { }, renamingFolderId: viewModel.renamingFolderId, allFolders: viewModel.nodes.collectFolders(), - teamLibraryAvailable: LicenseManager.shared.isFeatureAvailable(.teamLibrary) + teamLibraryAvailable: licenseManager.isFeatureAvailable(.teamLibrary) ), selection: $sharedSidebarState.selectedFavorite, - rowSizePreference: AppSettingsManager.shared.general.sidebarRowSize, + rowSizePreference: settingsManager.general.sidebarRowSize, actions: FavoritesOutlineActions( primaryAction: { handlePrimaryAction($0) }, deleteSelection: { deleteNode($0) }, @@ -319,7 +322,7 @@ internal struct FavoritesTabView: View { /// list grew with the sidebar size and the Favorites list beside it did not. private var resolvedRowSize: SidebarRowSize { SidebarRowSizeResolver.resolve( - preference: AppSettingsManager.shared.general.sidebarRowSize, + preference: settingsManager.general.sidebarRowSize, system: systemRowSize ) } @@ -358,12 +361,12 @@ internal struct FavoritesTabView: View { } icon: { Image(systemName: group.environment.iconName) } - .sidebarRowIcon(visible: AppSettingsManager.shared.general.showObjectIcons) + .sidebarRowIcon(visible: settingsManager.general.showObjectIcons) } private func favoriteDatabaseRow(_ entry: FavoriteDatabaseEntry) -> some View { Label(entry.database, systemImage: "cylinder") - .sidebarRowIcon(visible: AppSettingsManager.shared.general.showObjectIcons) + .sidebarRowIcon(visible: settingsManager.general.showObjectIcons) .lineLimit(1) .accessibilityLabel(String( format: String(localized: "%@: %@"), @@ -415,7 +418,7 @@ internal struct FavoritesTabView: View { Image(systemName: TableRowLogic.iconName(for: table.type)) .selectionAwareTint(Color.accentColor) } - .sidebarRowIcon(visible: AppSettingsManager.shared.general.showObjectIcons) + .sidebarRowIcon(visible: settingsManager.general.showObjectIcons) .accessibilityLabel( TableRowLogic.accessibilityLabel(table: table, isPendingDelete: false, isPendingTruncate: false) ) @@ -463,7 +466,7 @@ internal struct FavoritesTabView: View { break } case .teamQuery(let id, _, _): - guard let query = TeamLibrarySyncCoordinator.shared.library.queries.first(where: { $0.id == id }) + guard let query = teamLibrarySync.library.queries.first(where: { $0.id == id }) else { return } coordinator?.runFavoriteInNewTab(teamFavorite(from: query)) } diff --git a/TablePro/Views/Sidebar/ObjectCommentSheet.swift b/TablePro/Views/Sidebar/ObjectCommentSheet.swift index c094b5ae55..4326f77d85 100644 --- a/TablePro/Views/Sidebar/ObjectCommentSheet.swift +++ b/TablePro/Views/Sidebar/ObjectCommentSheet.swift @@ -12,6 +12,7 @@ import SwiftUI /// statement against the server, and a popover or an inspector field that saves when it loses /// focus would run it on a stray click. struct ObjectCommentSheet: View { + @ObservedObject private var themeEngine = ThemeEngine.shared private static let logger = Logger(subsystem: "com.TablePro", category: "ObjectCommentSheet") private enum Phase: Equatable { @@ -85,7 +86,7 @@ struct ObjectCommentSheet: View { private var editor: some View { VStack(alignment: .leading, spacing: 8) { TextEditor(text: $draft.text) - .font(ThemeEngine.shared.valueFontSwiftUI) + .font(themeEngine.valueFontSwiftUI) .focused($isEditorFocused) .disabled(phase == .saving) .overlay( diff --git a/TablePro/Views/Sidebar/RoutineRowView.swift b/TablePro/Views/Sidebar/RoutineRowView.swift index bae058ce7b..cc4515a5e2 100644 --- a/TablePro/Views/Sidebar/RoutineRowView.swift +++ b/TablePro/Views/Sidebar/RoutineRowView.swift @@ -37,6 +37,7 @@ enum RoutineRowLogic { } struct RoutineRowView: View { + @ObservedObject private var settingsManager = AppSettingsManager.shared let routine: RoutineInfo let displayLabel: String @@ -50,7 +51,7 @@ struct RoutineRowView: View { .selectionAwareTint(Color.accentColor) .frame(width: 16) } - .sidebarRowIcon(visible: AppSettingsManager.shared.general.showObjectIcons) + .sidebarRowIcon(visible: settingsManager.general.showObjectIcons) .accessibilityElement(children: .combine) .accessibilityLabel(RoutineRowLogic.accessibilityLabel(for: routine, displayLabel: displayLabel)) .help(RoutineRowLogic.tooltip(for: routine)) diff --git a/TablePro/Views/Sidebar/SidebarTreeView.swift b/TablePro/Views/Sidebar/SidebarTreeView.swift index 108d49f227..7205bb70c2 100644 --- a/TablePro/Views/Sidebar/SidebarTreeView.swift +++ b/TablePro/Views/Sidebar/SidebarTreeView.swift @@ -2,6 +2,7 @@ import SwiftUI import TableProPluginKit struct SidebarTreeView: View { + @ObservedObject private var databaseManager = DatabaseManager.shared @ObservedObject private var schemaService = SchemaService.shared let connectionId: UUID @@ -21,7 +22,7 @@ struct SidebarTreeView: View { } private var isConnected: Bool { - DatabaseManager.shared.session(for: connectionId)?.status == .connected + databaseManager.session(for: connectionId)?.status == .connected } private var systemSchemas: Set { diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 464f4920ba..cf4705e298 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -9,6 +9,8 @@ import SwiftUI import TableProPluginKit struct SidebarView: View { + @ObservedObject private var licenseManager = LicenseManager.shared + @ObservedObject private var databaseManager = DatabaseManager.shared @StateObject private var viewModel: SidebarViewModel @ObservedObject private var settingsManager = AppSettingsManager.shared @State private var showsSchemaProgress = false @@ -173,7 +175,7 @@ struct SidebarView: View { @ViewBuilder private var sidebarFooter: some View { - if showsSchemaPicker || LicenseManager.shared.supportAudience == .prospect { + if showsSchemaPicker || licenseManager.supportAudience == .prospect { VStack(spacing: 0) { Divider() HStack(spacing: 8) { @@ -325,7 +327,7 @@ struct SidebarView: View { } private var isConnected: Bool { - DatabaseManager.shared.session(for: connectionId)?.status == .connected + databaseManager.session(for: connectionId)?.status == .connected } private var tableList: some View { diff --git a/TablePro/Views/Sidebar/TableRowView.swift b/TablePro/Views/Sidebar/TableRowView.swift index 20a41f136d..17a0b06ea1 100644 --- a/TablePro/Views/Sidebar/TableRowView.swift +++ b/TablePro/Views/Sidebar/TableRowView.swift @@ -62,6 +62,7 @@ enum TableRowLogic { } struct TableRow: View { + @ObservedObject private var settingsManager = AppSettingsManager.shared let table: TableInfo let isPendingTruncate: Bool let isPendingDelete: Bool @@ -81,14 +82,14 @@ struct TableRow: View { } private var visibleComment: String? { - guard AppSettingsManager.shared.general.showObjectComments, + guard settingsManager.general.showObjectComments, let comment = table.comment, !comment.isEmpty else { return nil } return comment } private var showsObjectIcon: Bool { - AppSettingsManager.shared.general.showObjectIcons + settingsManager.general.showObjectIcons } private var showsLeadingIcon: Bool { diff --git a/TablePro/Views/Sidebar/TriggerRowView.swift b/TablePro/Views/Sidebar/TriggerRowView.swift index 73ae726a0a..660fff5af4 100644 --- a/TablePro/Views/Sidebar/TriggerRowView.swift +++ b/TablePro/Views/Sidebar/TriggerRowView.swift @@ -29,6 +29,7 @@ enum TriggerRowLogic { } struct TriggerRowView: View { + @ObservedObject private var settingsManager = AppSettingsManager.shared let trigger: TriggerInfo var body: some View { @@ -50,7 +51,7 @@ struct TriggerRowView: View { .selectionAwareTint(Color.accentColor) .frame(width: 16) } - .sidebarRowIcon(visible: AppSettingsManager.shared.general.showObjectIcons) + .sidebarRowIcon(visible: settingsManager.general.showObjectIcons) .accessibilityElement(children: .combine) .accessibilityLabel(TriggerRowLogic.accessibilityLabel(for: trigger)) .help(TriggerRowLogic.tooltip(for: trigger)) diff --git a/TablePro/Views/Sidebar/UserTypeRowView.swift b/TablePro/Views/Sidebar/UserTypeRowView.swift index 3d455b28fe..0aaa4855c6 100644 --- a/TablePro/Views/Sidebar/UserTypeRowView.swift +++ b/TablePro/Views/Sidebar/UserTypeRowView.swift @@ -32,6 +32,7 @@ enum UserTypeRowLogic { } struct UserTypeRowView: View { + @ObservedObject private var settingsManager = AppSettingsManager.shared let type: UserDefinedTypeInfo var body: some View { @@ -44,7 +45,7 @@ struct UserTypeRowView: View { .selectionAwareTint(Color.accentColor) .frame(width: 16) } - .sidebarRowIcon(visible: AppSettingsManager.shared.general.showObjectIcons) + .sidebarRowIcon(visible: settingsManager.general.showObjectIcons) .accessibilityElement(children: .combine) .accessibilityLabel(UserTypeRowLogic.accessibilityLabel(for: type)) .help(UserTypeRowLogic.tooltip(for: type)) diff --git a/TablePro/Views/Structure/CustomValueContentView.swift b/TablePro/Views/Structure/CustomValueContentView.swift index 91ef215f13..251636649b 100644 --- a/TablePro/Views/Structure/CustomValueContentView.swift +++ b/TablePro/Views/Structure/CustomValueContentView.swift @@ -13,6 +13,7 @@ import SwiftUI /// `pending` is a column reference, `'pending'` is a string. Guessing which one was meant is what /// made a default of `gen_random_uuid()` arrive at the server as the eleven-character string. internal struct CustomValueContentView: View { + @ObservedObject private var themeEngine = ThemeEngine.shared internal enum Mode: Hashable { case text case expression @@ -69,7 +70,7 @@ internal struct CustomValueContentView: View { TextField(placeholder, text: $text) .textFieldStyle(.roundedBorder) - .font(mode == .expression ? ThemeEngine.shared.valueFontSwiftUI : nil) + .font(mode == .expression ? themeEngine.valueFontSwiftUI : nil) .focused($isFieldFocused) .onSubmit(commit) diff --git a/TablePro/Views/Structure/DDLTextView.swift b/TablePro/Views/Structure/DDLTextView.swift index 36966c7431..2058626459 100644 --- a/TablePro/Views/Structure/DDLTextView.swift +++ b/TablePro/Views/Structure/DDLTextView.swift @@ -12,6 +12,7 @@ import TableProPluginKit /// Read-only DDL display with syntax highlighting powered by TableProEditorKit struct DDLTextView: View { + @ObservedObject private var settingsManager = AppSettingsManager.shared let ddl: String @Binding var fontSize: Double var databaseType: DatabaseType? diff --git a/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift index 08dd0d0ef3..793689cb9a 100644 --- a/TablePro/Views/Structure/TableStructureView.swift +++ b/TablePro/Views/Structure/TableStructureView.swift @@ -139,7 +139,11 @@ struct TableStructureView: View { nonmutating set { session.tabData = newValue } } - var structureChangeManager: StructureChangeManager { session.changeManager } + /// Observed in its own right, not reached through `session`. The session is observed, but a + /// change inside the manager it owns fires the manager's publisher and never the session's, so + /// every `onChange` below that reads the manager went deaf: staging a column, an index or a + /// foreign key reloaded no grid and left Save disabled, so Command+S did nothing. + @ObservedObject var structureChangeManager: StructureChangeManager @AppStorage("structureCodeFontSize", store: AppStorageEnvironment.shared.defaults) var ddlFontSize: Double = 13 @State var showCopyConfirmation = false @@ -172,6 +176,7 @@ struct TableStructureView: View { self.coordinator = coordinator self.selectionState = selectionState self.session = session + self.structureChangeManager = session.changeManager } var body: some View { diff --git a/TablePro/Views/Structure/TriggerEditorView.swift b/TablePro/Views/Structure/TriggerEditorView.swift index ac37869f37..27e04c967f 100644 --- a/TablePro/Views/Structure/TriggerEditorView.swift +++ b/TablePro/Views/Structure/TriggerEditorView.swift @@ -11,6 +11,7 @@ import TableProGrammars import TableProPluginKit struct TriggerEditorView: View { + @ObservedObject private var settingsManager = AppSettingsManager.shared enum Mode { case create case edit(originalName: String, originalDefinition: String) diff --git a/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift b/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift index 48a05d173a..52109bf7d5 100644 --- a/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift +++ b/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift @@ -38,6 +38,7 @@ struct ConnectionSwitcherEntry: Identifiable { } struct ConnectionSwitcherPopover: View { + @ObservedObject private var databaseManager = DatabaseManager.shared /// An explicit closure rather than `@Environment(\.dismiss)`, because the presenter owns the /// surface: `dismiss` reaches a SwiftUI presentation, and this content is hosted in an AppKit /// popover or panel that SwiftUI knows nothing about. `PopoverPresenter` hands every caller the @@ -58,7 +59,7 @@ struct ConnectionSwitcherPopover: View { static let contentSize = NSSize(width: 400, height: 460) private var activeSessions: [UUID: ConnectionSession] { - DatabaseManager.shared.activeSessions + databaseManager.activeSessions } private var currentConnection: DatabaseConnection? { @@ -326,7 +327,7 @@ struct ConnectionSwitcherPopover: View { hostedWithoutSession = ConnectionSwitcherSections.hostedWithoutSession( workspaces: WindowManager.shared.hostedWorkspaces().map { ($0.connectionId, $0.connection) }, - sessionIds: Set(DatabaseManager.shared.activeSessions.keys), + sessionIds: Set(databaseManager.activeSessions.keys), saved: saved ) } diff --git a/TablePro/Views/Welcome/WelcomeActionsPanel.swift b/TablePro/Views/Welcome/WelcomeActionsPanel.swift index b706b05455..9971079a6b 100644 --- a/TablePro/Views/Welcome/WelcomeActionsPanel.swift +++ b/TablePro/Views/Welcome/WelcomeActionsPanel.swift @@ -6,6 +6,7 @@ import SwiftUI struct WelcomeActionsPanel: View { + @ObservedObject private var licenseManager = LicenseManager.shared let onActivateLicense: () -> Void let onNewConnection: () -> Void let onOpenFile: () -> Void @@ -57,7 +58,7 @@ struct WelcomeActionsPanel: View { VStack(spacing: 4) { licenseBadge - if LicenseManager.shared.supportAudience == .prospect { + if licenseManager.supportAudience == .prospect { SupportPromptLink() } } @@ -124,7 +125,7 @@ struct WelcomeActionsPanel: View { /// pauses Pro features, and its owner is still not someone to ask for a purchase. @ViewBuilder private var licenseBadge: some View { - switch LicenseManager.shared.status { + switch licenseManager.status { case .active: Label(String(localized: "Pro"), systemImage: "checkmark.seal.fill") .font(.subheadline.weight(.medium)) diff --git a/TablePro/Views/Welcome/WelcomeOutlineRows.swift b/TablePro/Views/Welcome/WelcomeOutlineRows.swift index 8bb1538c76..85ba062cb7 100644 --- a/TablePro/Views/Welcome/WelcomeOutlineRows.swift +++ b/TablePro/Views/Welcome/WelcomeOutlineRows.swift @@ -150,10 +150,11 @@ private struct WelcomeConnectionAccessories: View { } private struct WelcomeConnectionStatus: View { + @ObservedObject private var databaseManager = DatabaseManager.shared let connectionId: UUID var body: some View { - switch DatabaseManager.shared.activeSessions[connectionId]?.reportedStatus { + switch databaseManager.activeSessions[connectionId]?.reportedStatus { case .connected: Text("Connected") .font(.caption) diff --git a/TableProTests/Core/ConnectionLibrary/ConnectionLibraryStorageTests.swift b/TableProTests/Core/ConnectionLibrary/ConnectionLibraryStorageTests.swift index f5c4b7d02c..8a63949c95 100644 --- a/TableProTests/Core/ConnectionLibrary/ConnectionLibraryStorageTests.swift +++ b/TableProTests/Core/ConnectionLibrary/ConnectionLibraryStorageTests.swift @@ -18,6 +18,9 @@ struct ConnectionLibraryStorageTests { private let appEvents: AppEvents private let storage: ConnectionStorage private let groupStorage: GroupStorage + /// The system keychain is out of reach in a test host, so a store stamped through it reads back + /// untrusted and the migration's trust assertion would turn on the environment. + private let integrity: ConnectionStoreIntegrity init() throws { let unique = UUID().uuidString @@ -31,12 +34,15 @@ struct ConnectionLibraryStorageTests { withIntermediateDirectories: true ) let events = AppEvents() + let storeIntegrity = ConnectionStoreIntegrity(keySource: StoredIntegrityKeySource(store: InMemoryKeychain())) let connectionStorage = ConnectionStorage( fileURL: storeURL, userDefaults: suiteDefaults, syncTracker: tracker, - appEvents: events + appEvents: events, + integrity: storeIntegrity ) + integrity = storeIntegrity defaults = suiteDefaults fileURL = storeURL appEvents = events @@ -150,15 +156,12 @@ struct ConnectionLibraryStorageTests { let first = DatabaseConnection(name: "First", type: .mysql, sortOrder: 0) let second = DatabaseConnection(name: "Second", type: .mysql, sortOrder: 0) #expect(storage.saveConnections([first, second])) - guard storage.storeIsTrusted else { - Issue.record("The connection store integrity key is unavailable in this test host") - return - } + #expect(storage.storeIsTrusted) - let migrating = ConnectionStorage(fileURL: fileURL, userDefaults: defaults) + let migrating = ConnectionStorage(fileURL: fileURL, userDefaults: defaults, integrity: integrity) #expect(migrating.loadConnections().map(\.sortOrder) == [0, 1]) - let relaunched = ConnectionStorage(fileURL: fileURL, userDefaults: defaults) + let relaunched = ConnectionStorage(fileURL: fileURL, userDefaults: defaults, integrity: integrity) _ = relaunched.loadConnections() #expect(relaunched.storeIsTrusted) } diff --git a/TableProTests/Core/Menu/MainMenuBuilderTests.swift b/TableProTests/Core/Menu/MainMenuBuilderTests.swift index 240a3274c6..42147ffb84 100644 --- a/TableProTests/Core/Menu/MainMenuBuilderTests.swift +++ b/TableProTests/Core/Menu/MainMenuBuilderTests.swift @@ -478,7 +478,9 @@ struct MainMenuValidationTests { context.isCurrentTabEditable = true context.isCurrentTabSchemaResolved = true context.hasTableSelection = true + context.hasRowSelection = true context.canTruncateSelectedTables = true + context.canDropSelectedTables = true context.canShowTableStructure = true context.canEditViewDefinition = true context.hasMaintenanceOperations = true diff --git a/TableProTests/Core/Storage/CredentialProfileStorageTests.swift b/TableProTests/Core/Storage/CredentialProfileStorageTests.swift index ff071f2e0e..16daa4a3c4 100644 --- a/TableProTests/Core/Storage/CredentialProfileStorageTests.swift +++ b/TableProTests/Core/Storage/CredentialProfileStorageTests.swift @@ -17,6 +17,9 @@ struct CredentialProfileStorageTests { private let keychain: InMemoryKeychain private let metadata: SyncMetadataStorage private let directory: URL + /// The system keychain is out of reach in a test host, so every store it stamps would read back + /// untrusted and the trust assertions here would pass or fail on the environment. + private let integrity: ConnectionStoreIntegrity init() { let unique = UUID().uuidString @@ -30,18 +33,21 @@ struct CredentialProfileStorageTests { userDefaults: UserDefaults(suiteName: "com.TablePro.tests.CredProfileSync.\(unique)")! ) let tracker = SyncChangeTracker(metadataStorage: metadata) + integrity = ConnectionStoreIntegrity(keySource: StoredIntegrityKeySource(store: keychain)) let connectionStorage = ConnectionStorage( fileURL: directory.appendingPathComponent("connections.json"), userDefaults: UserDefaults(suiteName: "com.TablePro.tests.CredProfileConn.\(unique)")!, syncTracker: tracker, - keychain: keychain + keychain: keychain, + integrity: integrity ) connections = connectionStorage storage = CredentialProfileStorage( fileURL: directory.appendingPathComponent("credentialProfiles.json"), keychain: keychain, syncTracker: tracker, - connectionStorage: connectionStorage + connectionStorage: connectionStorage, + integrity: integrity ) } @@ -152,7 +158,8 @@ struct CredentialProfileStorageTests { fileURL: lockedDirectory.appendingPathComponent("credentialProfiles.json"), keychain: lockedKeychain, syncTracker: SyncChangeTracker(metadataStorage: metadata), - connectionStorage: lockedConnections + connectionStorage: lockedConnections, + integrity: integrity ) let profile = makeProfile() @@ -180,7 +187,8 @@ struct CredentialProfileStorageTests { fileURL: fileURL, keychain: InMemoryKeychain(), syncTracker: SyncChangeTracker(metadataStorage: metadata), - connectionStorage: connections + connectionStorage: connections, + integrity: integrity ) #expect(corrupted.loadProfiles().isEmpty) @@ -209,7 +217,8 @@ struct CredentialProfileStorageTests { fileURL: fileURL, keychain: keychain, syncTracker: SyncChangeTracker(metadataStorage: metadata), - connectionStorage: connections + connectionStorage: connections, + integrity: integrity ) _ = reopened.loadProfiles() @@ -233,7 +242,8 @@ struct CredentialProfileStorageTests { fileURL: fileURL, keychain: keychain, syncTracker: SyncChangeTracker(metadataStorage: metadata), - connectionStorage: connections + connectionStorage: connections, + integrity: integrity ) #expect(store.loadProfiles().count == 1) #expect(!store.storeIsTrusted) @@ -255,7 +265,8 @@ struct CredentialProfileStorageTests { fileURL: fileURL, keychain: keychain, syncTracker: SyncChangeTracker(metadataStorage: metadata), - connectionStorage: connections + connectionStorage: connections, + integrity: integrity ) var connection = TestFixtures.makeConnection() connection.credentialMode = .profile(id: planted.id) diff --git a/TableProTests/Core/Transport/ConnectionTransportActivityTests.swift b/TableProTests/Core/Transport/ConnectionTransportActivityTests.swift index 48d9426c54..27d223eea5 100644 --- a/TableProTests/Core/Transport/ConnectionTransportActivityTests.swift +++ b/TableProTests/Core/Transport/ConnectionTransportActivityTests.swift @@ -93,6 +93,6 @@ struct ConnectionTransportActivityTests { func everyKindDeclaresMeasurability() { let measured = ConnectionTunnelKind.allCases.filter(\.carriesMeasuredBytes) - #expect(Set(measured) == Set([.ssh, .socksProxy])) + #expect(Set(measured) == Set([.ssh, .socksProxy, .remoteDatabaseSession])) } } diff --git a/TableProTests/Views/Editor/QueryCompletionAdapterLifecycleTests.swift b/TableProTests/Views/Editor/QueryCompletionAdapterLifecycleTests.swift index 2d74bb59c8..c87e03f2e0 100644 --- a/TableProTests/Views/Editor/QueryCompletionAdapterLifecycleTests.swift +++ b/TableProTests/Views/Editor/QueryCompletionAdapterLifecycleTests.swift @@ -122,6 +122,8 @@ struct QueryCompletionAdapterLifecycleTests { exact: String, longer: String ) async { + let keywordCase = PinnedKeywordCase(.upper) + defer { keywordCase.restore() } let labels = await incrementalLabels(opening: opening, typed: typed) #expect(labels.first == exact) @@ -135,6 +137,8 @@ struct QueryCompletionAdapterLifecycleTests { @MainActor @Test("typing into an open popup lands where reopening it would") func incrementalUpdateMatchesAFreshRequest() async { + let keywordCase = PinnedKeywordCase(.upper) + defer { keywordCase.restore() } let opened = "SELECT * FROM gt_user WHERE t" let completed = "SELECT * FROM gt_user WHERE true" @@ -175,6 +179,8 @@ struct QueryCompletionAdapterLifecycleTests { @MainActor @Test("deleting a character widens the list again") func deletingACharacterWidensTheList() async { + let keywordCase = PinnedKeywordCase(.upper) + defer { keywordCase.restore() } let opened = "SELECT * FROM gt_user WHERE t" let controller = EditorControllerFixture.make(string: opened) let adapter = QueryCompletionAdapter(schemaProvider: nil, databaseType: .mysql) @@ -212,6 +218,8 @@ struct QueryCompletionAdapterLifecycleTests { @MainActor @Test("a seeded session ranks its exact match first") func seededSessionRanksItsExactMatchFirst() async { + let keywordCase = PinnedKeywordCase(.upper) + defer { keywordCase.restore() } let suppressed = "SELECT * FROM users WHERE " let controller = EditorControllerFixture.make(string: suppressed) let adapter = QueryCompletionAdapter(schemaProvider: nil, databaseType: .mysql) @@ -305,6 +313,7 @@ struct QueryCompletionAdapterLifecycleTests { CursorPosition(range: NSRange(location: text.utf16.count, length: 0)) } + @MainActor private func incrementalLabels(opening: String, typed: String) async -> [String] { let queryPrefix = "SELECT * FROM gt_user WHERE " @@ -328,6 +337,26 @@ struct QueryCompletionAdapterLifecycleTests { } } +/// Holds `SQLKeywordCase` at one value for the length of a test, and puts the user's own back. +/// +/// A completion's label follows that setting, so every ranking assertion written against a +/// spelling would otherwise answer to whatever the machine running it has chosen: the same four +/// tests pass on a developer set to UPPERCASE and fail on CI, which takes the shipped default and +/// follows the lowercase prefix they type. +@MainActor +private struct PinnedKeywordCase { + private let previous: SQLKeywordCase + + init(_ value: SQLKeywordCase) { + previous = AppSettingsManager.shared.editor.keywordCase + AppSettingsManager.shared.editor.keywordCase = value + } + + func restore() { + AppSettingsManager.shared.editor.keywordCase = previous + } +} + /// Records what each incremental update was asked to rank, so a test can pin the pool's bound /// without reaching into the adapter's private session. @MainActor diff --git a/TableProTests/Views/Shared/FieldEditorResolverTests.swift b/TableProTests/Views/Shared/FieldEditorResolverTests.swift index 8bcf3ad3ef..2338699ec6 100644 --- a/TableProTests/Views/Shared/FieldEditorResolverTests.swift +++ b/TableProTests/Views/Shared/FieldEditorResolverTests.swift @@ -305,7 +305,9 @@ struct FieldEditorResolverImageTests { /// `jsonb[]` and `jsonb[][]` are one type in PostgreSQL's catalog and any array column may /// carry a dimension prefix, so the declared type cannot rule either out on a given row. The - /// text editor over the raw literal is the lossless fallback, as it is in the grid. + /// text editor over the raw literal is the lossless fallback, as it is in the grid, and which + /// text editor is the value's own length talking: both literals here sit under + /// `multiLineValueThreshold`. @Test("A literal the element list cannot represent falls back to the text editor") func unrepresentableArrayFallsBackToText() { #expect( @@ -313,7 +315,7 @@ struct FieldEditorResolverImageTests { for: jsonArrayType, isLongText: false, originalValue: #"{{"{\"id\": 1}"},{"{\"id\": 2}"}}"# - ) == .multiLine + ) == .singleLine ) #expect( FieldEditorResolver.resolve( diff --git a/TableProUITests/DiagramKeyboardZoomUITests.swift b/TableProUITests/DiagramKeyboardZoomUITests.swift index 13396035f3..61fd8a6635 100644 --- a/TableProUITests/DiagramKeyboardZoomUITests.swift +++ b/TableProUITests/DiagramKeyboardZoomUITests.swift @@ -74,10 +74,7 @@ final class DiagramKeyboardZoomUITests: UITestCase { private func runQuery(_ sql: String, in app: XCUIApplication) { app.typeKey("t", modifierFlags: .command) - let queryEditor = editorTextView(in: app) - XCTAssertTrue(queryEditor.waitToExist(timeout: 10)) - queryEditor.click() - app.typeText(sql) + typeQuery(sql, in: app) app.typeKey(.return, modifierFlags: .command) } } diff --git a/TableProUITests/DiagramPointerUITests.swift b/TableProUITests/DiagramPointerUITests.swift index 015a5c6893..bf89db39cf 100644 --- a/TableProUITests/DiagramPointerUITests.swift +++ b/TableProUITests/DiagramPointerUITests.swift @@ -92,10 +92,7 @@ final class DiagramPointerUITests: UITestCase { private func runQuery(_ sql: String, in app: XCUIApplication) { app.typeKey("t", modifierFlags: .command) - let queryEditor = editorTextView(in: app) - XCTAssertTrue(queryEditor.waitToExist(timeout: 10)) - queryEditor.click() - app.typeText(sql) + typeQuery(sql, in: app) app.typeKey(.return, modifierFlags: .command) } } diff --git a/TableProUITests/GridTimeZoneOffsetUITests.swift b/TableProUITests/GridTimeZoneOffsetUITests.swift index 1a2235ffe8..ada54d8bf4 100644 --- a/TableProUITests/GridTimeZoneOffsetUITests.swift +++ b/TableProUITests/GridTimeZoneOffsetUITests.swift @@ -35,7 +35,7 @@ final class GridTimeZoneOffsetUITests: UITestCase { queryEditor.click() paste(Self.setup, into: app) - openExecuteMenu(in: app).menuItems["Execute All Statements"].click() + openRunMenu(in: app).menuItems["Run All Statements"].click() confirmDestructiveExecution(in: app) guard waitForPredicate(timeout: 60, { self.offsetCell(in: app) != nil }) else { @@ -87,18 +87,18 @@ final class GridTimeZoneOffsetUITests: UITestCase { } /// The control is a split button: its leading half runs the query and only its trailing - /// chevron opens the menu, so a plain `click()` would execute instead of opening. + /// chevron opens the menu, so a plain `click()` would run instead of opening. /// /// The opened menu is scoped to the window rather than matched by identifier, because the CI /// runner's macOS build exposes a just-opened menu without one. - private func openExecuteMenu(in app: XCUIApplication) -> XCUIElement { + private func openRunMenu(in app: XCUIApplication) -> XCUIElement { let window = app.windows.firstMatch let executeMenu = window.descendants(matching: .any) - .matching(identifier: "query-execute-menu") + .matching(identifier: "query-run-menu") .firstMatch XCTAssertTrue( waitUntilHittable(executeMenu, timeout: 10), - "The editor toolbar must expose the Execute split button" + "The editor toolbar must expose the Run split button" ) executeMenu.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.5)).click() return window.menus.firstMatch diff --git a/TableProUITests/ImportFromAWSUITests.swift b/TableProUITests/ImportFromAWSUITests.swift index 0ec83cf827..3009dfe108 100644 --- a/TableProUITests/ImportFromAWSUITests.swift +++ b/TableProUITests/ImportFromAWSUITests.swift @@ -28,10 +28,13 @@ final class ImportFromAWSUITests: UITestCase { "File > Import > Import from AWS must open the discovery sheet" ) - XCTAssertTrue( - app.staticTexts["US East (N. Virginia)"].firstMatch.waitToExist(timeout: 5), - "The sheet must list the AWS regions to search" - ) + /// A region row is a checkbox whose label joins the region's name and its id + /// (`US East (N. Virginia), us-east-1`). The two `Text`s inside it are not published as + /// elements of their own, so matching a bare static text finds nothing. + let region = app.sheets.firstMatch.checkBoxes + .matching(NSPredicate(format: "label BEGINSWITH %@", "US East (N. Virginia)")) + .firstMatch + XCTAssertTrue(region.waitToExist(timeout: 5), "The sheet must list the AWS regions to search") let continueButton = app.buttons["aws-import-continue"] XCTAssertTrue(continueButton.waitToExist(timeout: 5), "The sheet must offer Continue") diff --git a/TableProUITests/QueryHistoryActionsUITests.swift b/TableProUITests/QueryHistoryActionsUITests.swift index ddc5e8c51c..d3e53eadb5 100644 --- a/TableProUITests/QueryHistoryActionsUITests.swift +++ b/TableProUITests/QueryHistoryActionsUITests.swift @@ -91,8 +91,7 @@ final class QueryHistoryActionsUITests: UITestCase { private func showHistoryDrawer(in app: XCUIApplication) { app.typeKey("y", modifierFlags: .command) - let detail = app.windows.firstMatch.descendants(matching: .any) - .matching(identifier: "query-history-detail").firstMatch + let detail = app.windows.firstMatch.groups.matching(identifier: "query-history-detail").firstMatch XCTAssertTrue(detail.waitToExist(timeout: 10), "Cmd+Y must show the query history drawer") } @@ -102,7 +101,10 @@ final class QueryHistoryActionsUITests: UITestCase { XCTAssertTrue(list.waitToExist(timeout: 10)) let row = list.tableRows.element(boundBy: 1) XCTAssertTrue(row.waitToExist(timeout: 10), "The drawer must list the query just run") - row.click() + /// Through a coordinate, for the reason `QueryHistoryFocusUITests` gives: `click()` on a row + /// AppKit reports as disabled selects it without a mouse-down, so the list never holds the + /// keyboard and Return reaches the editor instead of the list. + clickAtCenter(row) let preview = app.textViews["query-history-detail-query"] XCTAssertTrue(preview.waitToExist(timeout: 10)) diff --git a/TableProUITests/QueryHistoryFocusUITests.swift b/TableProUITests/QueryHistoryFocusUITests.swift index 5436bce87f..068c5cf828 100644 --- a/TableProUITests/QueryHistoryFocusUITests.swift +++ b/TableProUITests/QueryHistoryFocusUITests.swift @@ -23,7 +23,11 @@ final class QueryHistoryFocusUITests: UITestCase { waitUntilHittable(row, timeout: 10), "The drawer must list the queries just run and finish opening" ) - row.click() + /// Through a coordinate. AppKit publishes these rows as disabled, and `click()` on one + /// selects it through accessibility without delivering a mouse-down, so the table never + /// takes first responder and every key after it goes to the SQL editor instead. A person's + /// click is a mouse-down, which is what this sends. + clickAtCenter(row) /// The selection is asserted before the preview so a failure says which half broke: the /// preview is the detail pane drawing a selected row, so its absence alone cannot tell a diff --git a/TableProUITests/QueryHistoryPanelUITests.swift b/TableProUITests/QueryHistoryPanelUITests.swift index e828a2a27b..e3c862708e 100644 --- a/TableProUITests/QueryHistoryPanelUITests.swift +++ b/TableProUITests/QueryHistoryPanelUITests.swift @@ -23,7 +23,7 @@ final class QueryHistoryPanelUITests: UITestCase { XCTAssertTrue( waitForPredicate(timeout: 10) { - list.descendants(matching: .any) + list.staticTexts .matching(NSPredicate(format: "label CONTAINS[c] %@ OR value CONTAINS[c] %@", "Genre", "Genre")) .firstMatch.exists }, @@ -43,8 +43,7 @@ final class QueryHistoryPanelUITests: UITestCase { XCTAssertTrue(date.exists, "The drawer must expose the date range") XCTAssertEqual(scope.value as? String, "This Connection", "History starts scoped to this connection") - let detail = window.descendants(matching: .any) - .matching(identifier: "query-history-detail").firstMatch + let detail = window.groups.matching(identifier: "query-history-detail").firstMatch XCTAssertTrue(detail.waitToExist(timeout: 10), "The drawer is master-detail") app.typeKey("y", modifierFlags: .command) @@ -59,8 +58,7 @@ final class QueryHistoryPanelUITests: UITestCase { @discardableResult private func showHistoryDrawer(in app: XCUIApplication) -> XCUIElement { app.typeKey("y", modifierFlags: .command) - let list = app.windows.firstMatch.descendants(matching: .any) - .matching(identifier: "query-history-list").firstMatch + let list = app.windows.firstMatch.tables.matching(identifier: "query-history-list").firstMatch XCTAssertTrue(list.waitToExist(timeout: 10), "Cmd+Y must show the query history drawer") return list } diff --git a/TableProUITests/QueryPlanResultUITests.swift b/TableProUITests/QueryPlanResultUITests.swift index 323957028a..df72034653 100644 --- a/TableProUITests/QueryPlanResultUITests.swift +++ b/TableProUITests/QueryPlanResultUITests.swift @@ -234,19 +234,13 @@ final class QueryPlanResultUITests: UITestCase { private func runQuery(_ sql: String, in app: XCUIApplication) { app.typeKey("t", modifierFlags: .command) - let queryEditor = editorTextView(in: app) - XCTAssertTrue(queryEditor.waitToExist(timeout: 10)) - queryEditor.click() - app.typeText(sql) + typeQuery(sql, in: app) app.typeKey(.return, modifierFlags: .command) } private func runExplainAction(_ sql: String, in app: XCUIApplication) { app.typeKey("t", modifierFlags: .command) - let queryEditor = editorTextView(in: app) - XCTAssertTrue(queryEditor.waitToExist(timeout: 10)) - queryEditor.click() - app.typeText(sql) + typeQuery(sql, in: app) let explainButton = app.buttons["Explain"].firstMatch XCTAssertTrue(waitUntilHittable(explainButton, timeout: 10)) diff --git a/TableProUITests/ResultMapModeUITests.swift b/TableProUITests/ResultMapModeUITests.swift index b4219248c8..e0aa8acc56 100644 --- a/TableProUITests/ResultMapModeUITests.swift +++ b/TableProUITests/ResultMapModeUITests.swift @@ -127,7 +127,7 @@ final class ResultMapModeUITests: UITestCase { private func runSpatialSetup(in app: XCUIApplication) { app.typeKey("t", modifierFlags: .command) replaceEditorText(with: Self.spatialSetup, in: app) - openExecuteMenu(in: app).menuItems["Execute All Statements"].click() + openRunMenu(in: app).menuItems["Run All Statements"].click() let confirm = app.windows.firstMatch.sheets.firstMatch.buttons["Execute"] if confirm.waitToExist(timeout: 15) { confirm.click() } } @@ -159,14 +159,14 @@ final class ResultMapModeUITests: UITestCase { /// The control is a split button: its leading half runs the query and only its trailing chevron /// opens the menu, so a plain `click()` would execute instead of opening. Cmd+Return runs the /// statement under the cursor alone, which is not enough for a setup that creates a table. - private func openExecuteMenu(in app: XCUIApplication) -> XCUIElement { + private func openRunMenu(in app: XCUIApplication) -> XCUIElement { let window = app.windows.firstMatch let executeMenu = window.descendants(matching: .any) - .matching(identifier: "query-execute-menu") + .matching(identifier: "query-run-menu") .firstMatch XCTAssertTrue( waitUntilHittable(executeMenu, timeout: 15), - "The editor toolbar must expose the Execute split button" + "The editor toolbar must expose the Run split button" ) executeMenu.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.5)).click() return window.menus.firstMatch diff --git a/TableProUITests/Support/UITestCase.swift b/TableProUITests/Support/UITestCase.swift index fb9f4db6a8..b49b7fb327 100644 --- a/TableProUITests/Support/UITestCase.swift +++ b/TableProUITests/Support/UITestCase.swift @@ -277,6 +277,30 @@ internal class UITestCase: XCTestCase { return window.textViews.firstMatch } + /// Puts `sql` in the query editor and confirms it arrived, retyping it if it did not. + /// + /// A click focuses the editor, but the keystrokes that follow it can outrun the focus: the + /// editor installs its coordinators and its key monitor on a later run-loop turn, and anything + /// typed before that lands nowhere. Measured on this suite, `EXPLAIN QUERY PLAN SELECT …` + /// reached the editor as `IN QUERY PLAN SELECT …` and the query came back + /// `near "IN": syntax error`, which reads in the report as a broken query plan rather than as + /// five lost keystrokes. Select-all before each attempt, so a partial first attempt is replaced + /// rather than prepended to. + internal func typeQuery(_ sql: String, in app: XCUIApplication, attempts: Int = 3) { + let editor = editorTextView(in: app) + XCTAssertTrue(editor.waitToExist(timeout: 10), "The query tab must hold an editor to type into") + for _ in 0 ..< attempts { + editor.click() + app.typeKey("a", modifierFlags: .command) + app.typeText(sql) + let arrived = waitForPredicate(timeout: 3) { + ((editor.value as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines) == sql + } + if arrived { return } + } + XCTFail("The editor never received the query typed into it") + } + /// AppKit reports those rows as disabled, so they never become hittable and a plain `click()` /// waits for a state that cannot arrive. Clicking through a coordinate reaches them. internal func clickAtCenter(_ element: XCUIElement) { diff --git a/TableProUITests/WindowFocusUITests.swift b/TableProUITests/WindowFocusUITests.swift index 44e5a11688..0b661ebc84 100644 --- a/TableProUITests/WindowFocusUITests.swift +++ b/TableProUITests/WindowFocusUITests.swift @@ -12,14 +12,26 @@ import XCTest /// its parent, and a probe that resolves it leaves that menu open, so the click's own traversal then /// fails with "open menu during menu traversal". final class WindowFocusUITests: UITestCase { + /// Whether the keyboard is on this element. + /// /// `hasFocus` is declared on `XCUIElementAttributes` in ObjC /// (`XCUIAutomation.framework/Headers/XCUIElementAttributes.h:69`) and does not reach Swift: /// it appears in no `XCUIAutomation.swiftinterface` for this toolchain, and `hasKeyboardFocus` - /// is the iOS spelling. Key-value coding is what is left, and it is what an XCUITest - /// `NSPredicate` on the same attribute would use anyway, without that predicate's app-rooted - /// query walking the whole tree. + /// is the iOS spelling. Key-value coding is not the way round it either. Measured: it raises + /// `NSInternalInconsistencyException: Calling hasFocus on element is not supported on a macOS.`, + /// which took every test here with it. + /// + /// What macOS does publish is the snapshot XCUITest prints for itself. Its first line holds the + /// element's own attributes and carries `Keyboard Focused` when that element has the keyboard, + /// so that line is what this reads. Only the first: every `NSTableView` cell under a focused + /// list carries the same word, and the subtree below is not this element's answer. private func holdsKeyboardFocus(_ element: XCUIElement) -> Bool { - (element as NSObject).value(forKey: "hasFocus") as? Bool ?? false + guard element.exists else { return false } + let ownAttributes = element.debugDescription + .split(separator: "\n", maxSplits: 1, omittingEmptySubsequences: false) + .first + .map(String.init) ?? "" + return ownAttributes.contains("Keyboard Focused") } private func chooseFocusCommand(_ title: String, in app: XCUIApplication) {