diff --git a/CHANGELOG.md b/CHANGELOG.md index 251d08c362..9c91199c4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,7 +45,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Minimum macOS is now 14.4. +- Minimum macOS lowered to 13.0 (Ventura). - Toggle Filters on `Cmd+Shift+F`, leaving `Cmd+Option+F` to Find and Replace. - The editor's find panel keeps the mode it was left in instead of reverting to Find each time it opens. - Duplicate Connection shares a linked credential profile instead of copying its password. diff --git a/CLAUDE.md b/CLAUDE.md index 6de82e6244..b0de65e26d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ These govern every decision about code, architecture, tooling and process: ## Project Overview -TablePro is a native macOS database client (SwiftUI + AppKit), a fast, lightweight alternative to TablePlus. macOS 14.4+, `SWIFT_VERSION = 6.0` (`Configs/Base.xcconfig`), Universal Binary (arm64 + x86_64). +TablePro is a native macOS database client (SwiftUI + AppKit), a fast, lightweight alternative to TablePlus. macOS 13.0+, `SWIFT_VERSION = 6.0` (`Configs/Base.xcconfig`), Universal Binary (arm64 + x86_64). - **Source**: `TablePro/` holds `Core/` (business logic, services), `Views/` (UI), `Models/` (data structures), `ViewModels/`, `Extensions/` and `Theme/` - **Plugins**: `Plugins/` holds the `.tableplugin` bundles plus the `TableProPluginKit` shared framework. diff --git a/Packages/TableProCore/Package.swift b/Packages/TableProCore/Package.swift index cbaa4bb7e6..af2347376a 100644 --- a/Packages/TableProCore/Package.swift +++ b/Packages/TableProCore/Package.swift @@ -5,7 +5,7 @@ import PackageDescription let package = Package( name: "TableProCore", platforms: [ - .macOS(.v14), + .macOS(.v13), .iOS(.v17) ], products: [ diff --git a/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+Lifecycle.swift b/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+Lifecycle.swift index 69ddeb2a97..66e9683239 100644 --- a/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+Lifecycle.swift +++ b/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+Lifecycle.swift @@ -25,8 +25,11 @@ extension TextViewController { textCoordinators.forEach { $0.val?.controllerDidDisappear(controller: self) } } - override public func loadView() { // swiftlint:disable:this prohibited_super_call - super.loadView() + override public func loadView() { + /// Not `super.loadView()`. With a nil `nibName`, macOS 13 looks for a nib named after the + /// class and raises when there is none; macOS 14 quietly makes an empty view instead. + /// This controller has no nib on either, so it makes the view itself. + view = NSView() scrollView = SourceEditorScrollView() scrollView.documentView = textView diff --git a/Packages/TableProEditor/Sources/TableProEditorKit/Find/FindViewController.swift b/Packages/TableProEditor/Sources/TableProEditorKit/Find/FindViewController.swift index f5b9d2201e..19855ead21 100644 --- a/Packages/TableProEditor/Sources/TableProEditorKit/Find/FindViewController.swift +++ b/Packages/TableProEditor/Sources/TableProEditorKit/Find/FindViewController.swift @@ -46,8 +46,9 @@ final class FindViewController: NSViewController { fatalError("init(coder:) has not been implemented") } - override func loadView() { // swiftlint:disable:this prohibited_super_call - super.loadView() + override func loadView() { + /// See `TextViewController.loadView()`: `super` looks for a nib on macOS 13 and raises. + view = NSView() // Set up the `childView` as a subview of our view. Constrained to all edges, except the top is constrained to // the find panel's bottom diff --git a/Packages/TableProOracle/Package.swift b/Packages/TableProOracle/Package.swift index 1d0efa43cb..6e751bb4ad 100644 --- a/Packages/TableProOracle/Package.swift +++ b/Packages/TableProOracle/Package.swift @@ -5,7 +5,7 @@ import PackageDescription let package = Package( name: "TableProOracle", platforms: [ - .macOS(.v14), + .macOS(.v13), .iOS(.v17) ], products: [ diff --git a/Plugins/CSVExportPlugin/CSVExportOptionsView.swift b/Plugins/CSVExportPlugin/CSVExportOptionsView.swift index 948814974c..a35bd63f80 100644 --- a/Plugins/CSVExportPlugin/CSVExportOptionsView.swift +++ b/Plugins/CSVExportPlugin/CSVExportOptionsView.swift @@ -7,7 +7,7 @@ import SwiftUI import TableProPluginKit struct CSVExportOptionsView: View { - @Bindable var plugin: CSVExportPlugin + @ObservedObject var plugin: CSVExportPlugin var body: some View { VStack(alignment: .leading, spacing: 10) { diff --git a/Plugins/CSVExportPlugin/CSVExportPlugin.swift b/Plugins/CSVExportPlugin/CSVExportPlugin.swift index 1640b928b1..a816df925d 100644 --- a/Plugins/CSVExportPlugin/CSVExportPlugin.swift +++ b/Plugins/CSVExportPlugin/CSVExportPlugin.swift @@ -3,12 +3,12 @@ // CSVExportPlugin // +import Combine import Foundation import SwiftUI import TableProPluginKit -@Observable -final class CSVExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Sendable { +final class CSVExportPlugin: ObservableObject, ExportFormatPlugin, SettablePlugin, @unchecked Sendable { static let pluginName = "CSV Export" static let pluginVersion = "1.0.0" static let pluginDescription = "Export data to CSV format" @@ -23,7 +23,7 @@ final class CSVExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Send typealias Settings = CSVExportOptions static let settingsStorageId = "csv" - var settings = CSVExportOptions() { + @Published var settings = CSVExportOptions() { didSet { saveSettings() } } diff --git a/Plugins/CSVImportPlugin/CSVImportOptionsView.swift b/Plugins/CSVImportPlugin/CSVImportOptionsView.swift index dbcf583c46..6b42eb8a69 100644 --- a/Plugins/CSVImportPlugin/CSVImportOptionsView.swift +++ b/Plugins/CSVImportPlugin/CSVImportOptionsView.swift @@ -7,7 +7,7 @@ import SwiftUI import TableProPluginKit struct CSVImportOptionsView: View { - let plugin: CSVImportPlugin + @ObservedObject var plugin: CSVImportPlugin var body: some View { HStack(alignment: .top, spacing: 32) { @@ -15,7 +15,7 @@ struct CSVImportOptionsView: View { GridRow { Text("Delimiter:") .gridColumnAlignment(.trailing) - Picker(String(localized: "Delimiter", bundle: .main), selection: Bindable(plugin).settings.delimiter) { + Picker(String(localized: "Delimiter", bundle: .main), selection: $plugin.settings.delimiter) { Text("Auto-detect").tag(CSVImportOptions.Delimiter.auto) Text("Comma (,)").tag(CSVImportOptions.Delimiter.comma) Text("Semicolon (;)").tag(CSVImportOptions.Delimiter.semicolon) @@ -29,7 +29,7 @@ struct CSVImportOptionsView: View { GridRow { Text("Quote character:") - Picker(String(localized: "Quote character", bundle: .main), selection: Bindable(plugin).settings.quoteCharacter) { + Picker(String(localized: "Quote character", bundle: .main), selection: $plugin.settings.quoteCharacter) { Text("Double quote (\")").tag(CSVImportOptions.QuoteCharacter.doubleQuote) Text("Single quote (')").tag(CSVImportOptions.QuoteCharacter.singleQuote) } @@ -40,7 +40,7 @@ struct CSVImportOptionsView: View { GridRow { Text("Encoding:") - Picker(String(localized: "Encoding", bundle: .main), selection: Bindable(plugin).settings.encoding) { + Picker(String(localized: "Encoding", bundle: .main), selection: $plugin.settings.encoding) { Text("Auto-detect").tag(CSVImportOptions.TextEncoding.auto) Text("UTF-8").tag(CSVImportOptions.TextEncoding.utf8) Text("ISO Latin 1").tag(CSVImportOptions.TextEncoding.isoLatin1) @@ -53,7 +53,7 @@ struct CSVImportOptionsView: View { GridRow { Text("On error:") - Picker(String(localized: "On error", bundle: .main), selection: Bindable(plugin).settings.errorHandling) { + Picker(String(localized: "On error", bundle: .main), selection: $plugin.settings.errorHandling) { Text("Stop and Rollback").tag(ImportErrorHandling.stopAndRollback) Text("Stop and Commit").tag(ImportErrorHandling.stopAndCommit) Text("Skip and Continue").tag(ImportErrorHandling.skipAndContinue) @@ -65,7 +65,7 @@ struct CSVImportOptionsView: View { GridRow { Text("NULL text:") - TextField("", text: Bindable(plugin).settings.nullString, prompt: Text(verbatim: "\\N")) + TextField("", text: $plugin.settings.nullString, prompt: Text(verbatim: "\\N")) .textFieldStyle(.roundedBorder) .frame(width: 170) .help("An extra value that should be imported as NULL, for example \\N.") @@ -73,21 +73,21 @@ struct CSVImportOptionsView: View { } VStack(alignment: .leading, spacing: 10) { - Toggle("First row is a header", isOn: Bindable(plugin).settings.hasHeaderRow) + Toggle("First row is a header", isOn: $plugin.settings.hasHeaderRow) .help("Use the first row as column names. Turn off to import every row as data.") - Toggle("Trim leading and trailing spaces", isOn: Bindable(plugin).settings.trimWhitespace) + Toggle("Trim leading and trailing spaces", isOn: $plugin.settings.trimWhitespace) - Toggle("Treat empty values as NULL", isOn: Bindable(plugin).settings.emptyAsNull) + Toggle("Treat empty values as NULL", isOn: $plugin.settings.emptyAsNull) .help("Insert NULL for empty fields instead of an empty string.") - Toggle("Wrap in transaction (BEGIN/COMMIT)", isOn: Bindable(plugin).settings.wrapInTransaction) + Toggle("Wrap in transaction (BEGIN/COMMIT)", isOn: $plugin.settings.wrapInTransaction) .disabled(plugin.settings.errorHandling == .skipAndContinue) .help(plugin.settings.errorHandling == .skipAndContinue ? String(localized: "Not available in skip-and-continue mode") : String(localized: "Insert all rows in a single transaction. If any row fails, all changes are rolled back.")) - Toggle("Delete existing rows before import", isOn: Bindable(plugin).settings.deleteExistingRows) + Toggle("Delete existing rows before import", isOn: $plugin.settings.deleteExistingRows) .help("Remove every row from the target table before inserting the imported rows.") } } diff --git a/Plugins/CSVImportPlugin/CSVImportPlugin.swift b/Plugins/CSVImportPlugin/CSVImportPlugin.swift index d2b8a44dd0..639b4a50c1 100644 --- a/Plugins/CSVImportPlugin/CSVImportPlugin.swift +++ b/Plugins/CSVImportPlugin/CSVImportPlugin.swift @@ -3,12 +3,12 @@ // CSVImportPlugin // +import Combine import Foundation import SwiftUI import TableProPluginKit -@Observable -final class CSVImportPlugin: ImportFormatPlugin, SettablePlugin, @unchecked Sendable { +final class CSVImportPlugin: ObservableObject, ImportFormatPlugin, SettablePlugin, @unchecked Sendable { static let pluginName = "CSV Import" static let pluginVersion = "1.0.0" static let pluginDescription = "Import data from CSV and TSV files" @@ -21,7 +21,7 @@ final class CSVImportPlugin: ImportFormatPlugin, SettablePlugin, @unchecked Send typealias Settings = CSVImportOptions static let settingsStorageId = "csv-import" - var settings = CSVImportOptions() { + @Published var settings = CSVImportOptions() { didSet { saveSettings() } } diff --git a/Plugins/HTMLExportPlugin/HTMLExportOptionsView.swift b/Plugins/HTMLExportPlugin/HTMLExportOptionsView.swift index cb5d6f65bb..3a80155138 100644 --- a/Plugins/HTMLExportPlugin/HTMLExportOptionsView.swift +++ b/Plugins/HTMLExportPlugin/HTMLExportOptionsView.swift @@ -6,7 +6,7 @@ import SwiftUI struct HTMLExportOptionsView: View { - @Bindable var plugin: HTMLExportPlugin + @ObservedObject var plugin: HTMLExportPlugin var body: some View { VStack(alignment: .leading, spacing: 8) { diff --git a/Plugins/HTMLExportPlugin/HTMLExportPlugin.swift b/Plugins/HTMLExportPlugin/HTMLExportPlugin.swift index 39b0a9073a..f030de020b 100644 --- a/Plugins/HTMLExportPlugin/HTMLExportPlugin.swift +++ b/Plugins/HTMLExportPlugin/HTMLExportPlugin.swift @@ -3,13 +3,13 @@ // HTMLExportPlugin // +import Combine import Foundation import os import SwiftUI import TableProPluginKit -@Observable -final class HTMLExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Sendable { +final class HTMLExportPlugin: ObservableObject, ExportFormatPlugin, SettablePlugin, @unchecked Sendable { static let pluginName = "HTML Export" static let pluginVersion = "1.0.0" static let pluginDescription = "Export data to an HTML table" @@ -21,7 +21,7 @@ final class HTMLExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Sen typealias Settings = HTMLExportOptions static let settingsStorageId = "html" - var settings = HTMLExportOptions() { + @Published var settings = HTMLExportOptions() { didSet { saveSettings() } } diff --git a/Plugins/JSONExportPlugin/JSONExportOptionsView.swift b/Plugins/JSONExportPlugin/JSONExportOptionsView.swift index 475e495881..aa0bfa297e 100644 --- a/Plugins/JSONExportPlugin/JSONExportOptionsView.swift +++ b/Plugins/JSONExportPlugin/JSONExportOptionsView.swift @@ -6,7 +6,7 @@ import SwiftUI struct JSONExportOptionsView: View { - @Bindable var plugin: JSONExportPlugin + @ObservedObject var plugin: JSONExportPlugin var body: some View { VStack(alignment: .leading, spacing: 8) { diff --git a/Plugins/JSONExportPlugin/JSONExportPlugin.swift b/Plugins/JSONExportPlugin/JSONExportPlugin.swift index a9f7d3f634..a1d9c7772d 100644 --- a/Plugins/JSONExportPlugin/JSONExportPlugin.swift +++ b/Plugins/JSONExportPlugin/JSONExportPlugin.swift @@ -3,12 +3,12 @@ // JSONExportPlugin // +import Combine import Foundation import SwiftUI import TableProPluginKit -@Observable -final class JSONExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Sendable { +final class JSONExportPlugin: ObservableObject, ExportFormatPlugin, SettablePlugin, @unchecked Sendable { static let pluginName = "JSON Export" static let pluginVersion = "1.0.0" static let pluginDescription = "Export data to JSON format" @@ -20,7 +20,7 @@ final class JSONExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Sen typealias Settings = JSONExportOptions static let settingsStorageId = "json" - var settings = JSONExportOptions() { + @Published var settings = JSONExportOptions() { didSet { saveSettings() } } diff --git a/Plugins/JSONImportPlugin/JSONImportOptionsView.swift b/Plugins/JSONImportPlugin/JSONImportOptionsView.swift index dadc301357..fb9d481504 100644 --- a/Plugins/JSONImportPlugin/JSONImportOptionsView.swift +++ b/Plugins/JSONImportPlugin/JSONImportOptionsView.swift @@ -7,11 +7,11 @@ import SwiftUI import TableProPluginKit struct JSONImportOptionsView: View { - let plugin: JSONImportPlugin + @ObservedObject var plugin: JSONImportPlugin var body: some View { VStack(alignment: .leading, spacing: 12) { - Picker("On error:", selection: Bindable(plugin).settings.errorHandling) { + Picker("On error:", selection: $plugin.settings.errorHandling) { Text("Stop and Rollback").tag(ImportErrorHandling.stopAndRollback) Text("Stop and Commit").tag(ImportErrorHandling.stopAndCommit) Text("Skip and Continue").tag(ImportErrorHandling.skipAndContinue) @@ -19,14 +19,14 @@ struct JSONImportOptionsView: View { .pickerStyle(.menu) .font(.system(size: 13)) - Toggle("Wrap in transaction (BEGIN/COMMIT)", isOn: Bindable(plugin).settings.wrapInTransaction) + Toggle("Wrap in transaction (BEGIN/COMMIT)", isOn: $plugin.settings.wrapInTransaction) .font(.system(size: 13)) .disabled(plugin.settings.errorHandling == .skipAndContinue) .help(plugin.settings.errorHandling == .skipAndContinue ? String(localized: "Not available in skip-and-continue mode") : String(localized: "Insert all rows in a single transaction. If any row fails, all changes are rolled back.")) - Toggle("Delete existing rows before import", isOn: Bindable(plugin).settings.deleteExistingRows) + Toggle("Delete existing rows before import", isOn: $plugin.settings.deleteExistingRows) .font(.system(size: 13)) .help("Remove every row from the target table before inserting the imported rows.") } diff --git a/Plugins/JSONImportPlugin/JSONImportPlugin.swift b/Plugins/JSONImportPlugin/JSONImportPlugin.swift index beca322777..19161b0b4a 100644 --- a/Plugins/JSONImportPlugin/JSONImportPlugin.swift +++ b/Plugins/JSONImportPlugin/JSONImportPlugin.swift @@ -3,12 +3,12 @@ // JSONImportPlugin // +import Combine import Foundation import SwiftUI import TableProPluginKit -@Observable -final class JSONImportPlugin: ImportFormatPlugin, SettablePlugin, @unchecked Sendable { +final class JSONImportPlugin: ObservableObject, ImportFormatPlugin, SettablePlugin, @unchecked Sendable { static let pluginName = "JSON Import" static let pluginVersion = "1.0.0" static let pluginDescription = "Import data from JSON files" @@ -21,7 +21,7 @@ final class JSONImportPlugin: ImportFormatPlugin, SettablePlugin, @unchecked Sen typealias Settings = JSONImportOptions static let settingsStorageId = "json-import" - var settings = JSONImportOptions() { + @Published var settings = JSONImportOptions() { didSet { saveSettings() } } diff --git a/Plugins/MQLExportPlugin/MQLExportOptionsView.swift b/Plugins/MQLExportPlugin/MQLExportOptionsView.swift index 6e6579ad66..4cd520493c 100644 --- a/Plugins/MQLExportPlugin/MQLExportOptionsView.swift +++ b/Plugins/MQLExportPlugin/MQLExportOptionsView.swift @@ -6,7 +6,7 @@ import SwiftUI struct MQLExportOptionsView: View { - @Bindable var plugin: MQLExportPlugin + @ObservedObject var plugin: MQLExportPlugin private static let batchSizeOptions = [100, 500, 1_000, 5_000] diff --git a/Plugins/MQLExportPlugin/MQLExportPlugin.swift b/Plugins/MQLExportPlugin/MQLExportPlugin.swift index 94f6c5b653..ce67449432 100644 --- a/Plugins/MQLExportPlugin/MQLExportPlugin.swift +++ b/Plugins/MQLExportPlugin/MQLExportPlugin.swift @@ -3,12 +3,12 @@ // MQLExportPlugin // +import Combine import Foundation import SwiftUI import TableProPluginKit -@Observable -final class MQLExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Sendable { +final class MQLExportPlugin: ObservableObject, ExportFormatPlugin, SettablePlugin, @unchecked Sendable { static let pluginName = "MQL Export" static let pluginVersion = "1.0.0" static let pluginDescription = "Export data to MongoDB Query Language format" @@ -27,7 +27,7 @@ final class MQLExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Send typealias Settings = MQLExportOptions static let settingsStorageId = "mql" - var settings = MQLExportOptions() { + @Published var settings = MQLExportOptions() { didSet { saveSettings() } } diff --git a/Plugins/MarkdownExportPlugin/MarkdownExportOptionsView.swift b/Plugins/MarkdownExportPlugin/MarkdownExportOptionsView.swift index 26f7ccfe7e..38d64617d7 100644 --- a/Plugins/MarkdownExportPlugin/MarkdownExportOptionsView.swift +++ b/Plugins/MarkdownExportPlugin/MarkdownExportOptionsView.swift @@ -6,7 +6,7 @@ import SwiftUI struct MarkdownExportOptionsView: View { - @Bindable var plugin: MarkdownExportPlugin + @ObservedObject var plugin: MarkdownExportPlugin var body: some View { VStack(alignment: .leading, spacing: 8) { diff --git a/Plugins/MarkdownExportPlugin/MarkdownExportPlugin.swift b/Plugins/MarkdownExportPlugin/MarkdownExportPlugin.swift index d48ed9c57e..2ee2f17664 100644 --- a/Plugins/MarkdownExportPlugin/MarkdownExportPlugin.swift +++ b/Plugins/MarkdownExportPlugin/MarkdownExportPlugin.swift @@ -3,13 +3,13 @@ // MarkdownExportPlugin // +import Combine import Foundation import os import SwiftUI import TableProPluginKit -@Observable -final class MarkdownExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Sendable { +final class MarkdownExportPlugin: ObservableObject, ExportFormatPlugin, SettablePlugin, @unchecked Sendable { static let pluginName = "Markdown Export" static let pluginVersion = "1.0.0" static let pluginDescription = "Export data to Markdown tables" @@ -25,7 +25,7 @@ final class MarkdownExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked /// would mean holding the whole table in memory to decide a column width. private static let alignmentSampleRows = 200 - var settings = MarkdownExportOptions() { + @Published var settings = MarkdownExportOptions() { didSet { saveSettings() } } diff --git a/Plugins/ParquetExportPlugin/ParquetExportOptionsView.swift b/Plugins/ParquetExportPlugin/ParquetExportOptionsView.swift index 7bb174d3fa..bca73cfa42 100644 --- a/Plugins/ParquetExportPlugin/ParquetExportOptionsView.swift +++ b/Plugins/ParquetExportPlugin/ParquetExportOptionsView.swift @@ -6,7 +6,7 @@ import SwiftUI struct ParquetExportOptionsView: View { - @Bindable var plugin: ParquetExportPlugin + @ObservedObject var plugin: ParquetExportPlugin private static let rowGroupSizes = [10_000, 50_000, 122_880, 500_000, 1_000_000] diff --git a/Plugins/ParquetExportPlugin/ParquetExportPlugin.swift b/Plugins/ParquetExportPlugin/ParquetExportPlugin.swift index f11656c0c7..d2105f5071 100644 --- a/Plugins/ParquetExportPlugin/ParquetExportPlugin.swift +++ b/Plugins/ParquetExportPlugin/ParquetExportPlugin.swift @@ -4,6 +4,7 @@ // import CDuckDB +import Combine import Foundation import os import SwiftUI @@ -15,8 +16,7 @@ import TableProPluginKit /// column chunks, with per-column compression. Hand-writing that means owning an encoder whose /// correctness nothing local can check. DuckDB already ships one that every Parquet reader agrees /// with, and the library is already built for this app. -@Observable -final class ParquetExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Sendable { +final class ParquetExportPlugin: ObservableObject, ExportFormatPlugin, SettablePlugin, @unchecked Sendable { static let pluginName = "Parquet Export" static let pluginVersion = "1.0.0" static let pluginDescription = "Export data to Apache Parquet" @@ -36,7 +36,7 @@ final class ParquetExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked /// many rows are held in Swift at once. private static let stagingBatchSize = 2_000 - var settings = ParquetExportOptions() { + @Published var settings = ParquetExportOptions() { didSet { saveSettings() } } diff --git a/Plugins/SQLExportPlugin/SQLExportOptionsView.swift b/Plugins/SQLExportPlugin/SQLExportOptionsView.swift index 68b25c9820..9ce160c422 100644 --- a/Plugins/SQLExportPlugin/SQLExportOptionsView.swift +++ b/Plugins/SQLExportPlugin/SQLExportOptionsView.swift @@ -6,7 +6,7 @@ import SwiftUI struct SQLExportOptionsView: View { - @Bindable var plugin: SQLExportPlugin + @ObservedObject var plugin: SQLExportPlugin private static let batchSizeOptions = [1, 100, 500, 1_000] diff --git a/Plugins/SQLExportPlugin/SQLExportPlugin.swift b/Plugins/SQLExportPlugin/SQLExportPlugin.swift index bfbdf4bb16..764831aa94 100644 --- a/Plugins/SQLExportPlugin/SQLExportPlugin.swift +++ b/Plugins/SQLExportPlugin/SQLExportPlugin.swift @@ -3,13 +3,13 @@ // SQLExportPlugin // +import Combine import Foundation import os import SwiftUI import TableProPluginKit -@Observable -final class SQLExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Sendable { +final class SQLExportPlugin: ObservableObject, ExportFormatPlugin, SettablePlugin, @unchecked Sendable { static let pluginName = "SQL Export" static let pluginVersion = "1.0.0" static let pluginDescription = "Export data to SQL format" @@ -44,7 +44,7 @@ final class SQLExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Send typealias Settings = SQLExportOptions static let settingsStorageId = "sql" - var settings = SQLExportOptions() { + @Published var settings = SQLExportOptions() { didSet { saveSettings() } } diff --git a/Plugins/SQLImportPlugin/SQLImportOptionsView.swift b/Plugins/SQLImportPlugin/SQLImportOptionsView.swift index c73930742a..527775e6fa 100644 --- a/Plugins/SQLImportPlugin/SQLImportOptionsView.swift +++ b/Plugins/SQLImportPlugin/SQLImportOptionsView.swift @@ -7,11 +7,11 @@ import SwiftUI import TableProPluginKit struct SQLImportOptionsView: View { - let plugin: SQLImportPlugin + @ObservedObject var plugin: SQLImportPlugin var body: some View { VStack(alignment: .leading, spacing: 12) { - Picker("On error:", selection: Bindable(plugin).settings.errorHandling) { + Picker("On error:", selection: $plugin.settings.errorHandling) { Text("Stop and Rollback").tag(ImportErrorHandling.stopAndRollback) Text("Stop and Commit").tag(ImportErrorHandling.stopAndCommit) Text("Skip and Continue").tag(ImportErrorHandling.skipAndContinue) @@ -19,14 +19,14 @@ struct SQLImportOptionsView: View { .pickerStyle(.menu) .font(.system(size: 13)) - Toggle("Wrap in transaction (BEGIN/COMMIT)", isOn: Bindable(plugin).settings.wrapInTransaction) + Toggle("Wrap in transaction (BEGIN/COMMIT)", isOn: $plugin.settings.wrapInTransaction) .font(.system(size: 13)) .disabled(plugin.settings.errorHandling == .skipAndContinue) .help(plugin.settings.errorHandling == .skipAndContinue ? String(localized: "Not available in skip-and-continue mode") : String(localized: "Execute all statements in a single transaction. If any statement fails, all changes are rolled back.")) - Toggle("Disable foreign key checks", isOn: Bindable(plugin).settings.disableForeignKeyChecks) + Toggle("Disable foreign key checks", isOn: $plugin.settings.disableForeignKeyChecks) .font(.system(size: 13)) .help( "Temporarily disable foreign key constraints during import. Useful for importing data with circular dependencies." diff --git a/Plugins/SQLImportPlugin/SQLImportPlugin.swift b/Plugins/SQLImportPlugin/SQLImportPlugin.swift index ce03a3de99..ec4905a4e7 100644 --- a/Plugins/SQLImportPlugin/SQLImportPlugin.swift +++ b/Plugins/SQLImportPlugin/SQLImportPlugin.swift @@ -3,13 +3,13 @@ // SQLImportPlugin // +import Combine import Foundation import os import SwiftUI import TableProPluginKit -@Observable -final class SQLImportPlugin: ImportFormatPlugin, SettablePlugin, @unchecked Sendable { +final class SQLImportPlugin: ObservableObject, ImportFormatPlugin, SettablePlugin, @unchecked Sendable { private static let logger = Logger(subsystem: "com.TablePro", category: "SQLImportPlugin") static let pluginName = "SQL Import" @@ -23,7 +23,7 @@ final class SQLImportPlugin: ImportFormatPlugin, SettablePlugin, @unchecked Send typealias Settings = SQLImportOptions static let settingsStorageId = "sql-import" - var settings = SQLImportOptions() { + @Published var settings = SQLImportOptions() { didSet { saveSettings() } } diff --git a/Plugins/XLSXExportPlugin/XLSXExportOptionsView.swift b/Plugins/XLSXExportPlugin/XLSXExportOptionsView.swift index aedc41b6d9..39aafdb7bf 100644 --- a/Plugins/XLSXExportPlugin/XLSXExportOptionsView.swift +++ b/Plugins/XLSXExportPlugin/XLSXExportOptionsView.swift @@ -6,7 +6,7 @@ import SwiftUI struct XLSXExportOptionsView: View { - @Bindable var plugin: XLSXExportPlugin + @ObservedObject var plugin: XLSXExportPlugin var body: some View { VStack(alignment: .leading, spacing: 8) { diff --git a/Plugins/XLSXExportPlugin/XLSXExportPlugin.swift b/Plugins/XLSXExportPlugin/XLSXExportPlugin.swift index 4efacad608..423c408c21 100644 --- a/Plugins/XLSXExportPlugin/XLSXExportPlugin.swift +++ b/Plugins/XLSXExportPlugin/XLSXExportPlugin.swift @@ -3,12 +3,12 @@ // XLSXExportPlugin // +import Combine import Foundation import SwiftUI import TableProPluginKit -@Observable -final class XLSXExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Sendable { +final class XLSXExportPlugin: ObservableObject, ExportFormatPlugin, SettablePlugin, @unchecked Sendable { static let pluginName = "XLSX Export" static let pluginVersion = "1.0.0" static let pluginDescription = "Export data to Excel format" @@ -20,7 +20,7 @@ final class XLSXExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Sen typealias Settings = XLSXExportOptions static let settingsStorageId = "xlsx" - var settings = XLSXExportOptions() { + @Published var settings = XLSXExportOptions() { didSet { saveSettings() } } diff --git a/Plugins/XLSXImportPlugin/XLSXImportOptionsView.swift b/Plugins/XLSXImportPlugin/XLSXImportOptionsView.swift index dbde39d0b7..84df672a5c 100644 --- a/Plugins/XLSXImportPlugin/XLSXImportOptionsView.swift +++ b/Plugins/XLSXImportPlugin/XLSXImportOptionsView.swift @@ -7,7 +7,7 @@ import SwiftUI import TableProPluginKit struct XLSXImportOptionsView: View { - @Bindable var plugin: XLSXImportPlugin + @ObservedObject var plugin: XLSXImportPlugin var body: some View { VStack(alignment: .leading, spacing: 8) { diff --git a/Plugins/XLSXImportPlugin/XLSXImportPlugin.swift b/Plugins/XLSXImportPlugin/XLSXImportPlugin.swift index 1d7abaab47..8aaa2625b7 100644 --- a/Plugins/XLSXImportPlugin/XLSXImportPlugin.swift +++ b/Plugins/XLSXImportPlugin/XLSXImportPlugin.swift @@ -3,13 +3,13 @@ // XLSXImportPlugin // +import Combine import Foundation import os import SwiftUI import TableProPluginKit -@Observable -final class XLSXImportPlugin: ImportFormatPlugin, SettablePlugin, @unchecked Sendable { +final class XLSXImportPlugin: ObservableObject, ImportFormatPlugin, SettablePlugin, @unchecked Sendable { static let pluginName = "XLSX Import" static let pluginVersion = "1.0.0" static let pluginDescription = "Import data from Excel workbooks" @@ -28,7 +28,7 @@ final class XLSXImportPlugin: ImportFormatPlugin, SettablePlugin, @unchecked Sen /// already in memory by then, so this only bounds the inference work. private static let detectionSampleRows = 200 - var settings = XLSXImportOptions() { + @Published var settings = XLSXImportOptions() { didSet { saveSettings() } } diff --git a/Plugins/XMLExportPlugin/XMLExportOptionsView.swift b/Plugins/XMLExportPlugin/XMLExportOptionsView.swift index 13615ab9e0..297a391e37 100644 --- a/Plugins/XMLExportPlugin/XMLExportOptionsView.swift +++ b/Plugins/XMLExportPlugin/XMLExportOptionsView.swift @@ -6,7 +6,7 @@ import SwiftUI struct XMLExportOptionsView: View { - @Bindable var plugin: XMLExportPlugin + @ObservedObject var plugin: XMLExportPlugin var body: some View { VStack(alignment: .leading, spacing: 8) { diff --git a/Plugins/XMLExportPlugin/XMLExportPlugin.swift b/Plugins/XMLExportPlugin/XMLExportPlugin.swift index 5521c7d330..d83d3b3b77 100644 --- a/Plugins/XMLExportPlugin/XMLExportPlugin.swift +++ b/Plugins/XMLExportPlugin/XMLExportPlugin.swift @@ -3,13 +3,13 @@ // XMLExportPlugin // +import Combine import Foundation import os import SwiftUI import TableProPluginKit -@Observable -final class XMLExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Sendable { +final class XMLExportPlugin: ObservableObject, ExportFormatPlugin, SettablePlugin, @unchecked Sendable { static let pluginName = "XML Export" static let pluginVersion = "1.0.0" static let pluginDescription = "Export data to XML" @@ -21,7 +21,7 @@ final class XMLExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Send typealias Settings = XMLExportOptions static let settingsStorageId = "xml" - var settings = XMLExportOptions() { + @Published var settings = XMLExportOptions() { didSet { saveSettings() } } diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index e5af9b391e..22f2ce85fa 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -31,7 +31,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { _ = InspectorDocumentController() guard ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil else { return } - FeatureTipsBootstrap.configure() + if #available(macOS 14.0, *) { + FeatureTipsBootstrap.configure() + } PluginManager.shared.loadPlugins() LaunchTracer.shared.mark(.pluginsDiscovered) LaunchTracer.shared.mark(.willFinishLaunchingEnded) diff --git a/TablePro/Core/AI/Chat/ChatTurn.swift b/TablePro/Core/AI/Chat/ChatTurn.swift index 535542f63d..1cde5b53f5 100644 --- a/TablePro/Core/AI/Chat/ChatTurn.swift +++ b/TablePro/Core/AI/Chat/ChatTurn.swift @@ -3,8 +3,8 @@ // TablePro // +import Combine import Foundation -import Observation enum ChatRole: String, Codable, Sendable { case user @@ -22,11 +22,11 @@ enum ChatContentBlockKind: Sendable, Equatable { case sqlWalkthrough(SqlWalkthroughBlock) } -@MainActor @Observable -final class ChatContentBlock: Identifiable { +@MainActor +final class ChatContentBlock: ObservableObject, Identifiable { let id: UUID - var kind: ChatContentBlockKind - var isStreaming: Bool + @Published var kind: ChatContentBlockKind + @Published var isStreaming: Bool init(id: UUID = UUID(), kind: ChatContentBlockKind, isStreaming: Bool = false) { self.id = id @@ -94,15 +94,15 @@ extension ChatContentBlock { } } -@MainActor @Observable -final class ChatTurn: Identifiable { +@MainActor +final class ChatTurn: ObservableObject, Identifiable { let id: UUID let role: ChatRole - var blocks: [ChatContentBlock] + @Published var blocks: [ChatContentBlock] let timestamp: Date - var usage: AITokenUsage? - var modelId: String? - var providerId: String? + @Published var usage: AITokenUsage? + @Published var modelId: String? + @Published var providerId: String? init( id: UUID = UUID(), diff --git a/TablePro/Core/AI/ChatGPTCodex/ChatGPTCodexService.swift b/TablePro/Core/AI/ChatGPTCodex/ChatGPTCodexService.swift index 9723bbbc3e..d90abafec3 100644 --- a/TablePro/Core/AI/ChatGPTCodex/ChatGPTCodexService.swift +++ b/TablePro/Core/AI/ChatGPTCodex/ChatGPTCodexService.swift @@ -4,11 +4,12 @@ // import AppKit +import Combine import Foundation import os -@MainActor @Observable -final class ChatGPTCodexService { +@MainActor +final class ChatGPTCodexService: ObservableObject { static let shared = ChatGPTCodexService() nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "ChatGPTCodexService") @@ -24,11 +25,11 @@ final class ChatGPTCodexService { } } - private(set) var authState: AuthState = .signedOut - private(set) var errorMessage: String? + @Published private(set) var authState: AuthState = .signedOut + @Published private(set) var errorMessage: String? - @ObservationIgnored private let tokenStore: ChatGPTCodexTokenStore - @ObservationIgnored private let oauthClient: ChatGPTCodexOAuthClient + private let tokenStore: ChatGPTCodexTokenStore + private let oauthClient: ChatGPTCodexOAuthClient init( tokenStore: ChatGPTCodexTokenStore = .shared, diff --git a/TablePro/Core/AI/ClaudeAgent/ClaudeAgentService.swift b/TablePro/Core/AI/ClaudeAgent/ClaudeAgentService.swift index c5cf4510aa..5ff1f338bf 100644 --- a/TablePro/Core/AI/ClaudeAgent/ClaudeAgentService.swift +++ b/TablePro/Core/AI/ClaudeAgent/ClaudeAgentService.swift @@ -3,11 +3,12 @@ // TablePro // +import Combine import Foundation import os -@MainActor @Observable -final class ClaudeAgentService { +@MainActor +final class ClaudeAgentService: ObservableObject { enum InstallState: Equatable { case unknown case notInstalled @@ -25,8 +26,8 @@ final class ClaudeAgentService { nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "ClaudeAgentService") - private(set) var state: InstallState = .unknown - private(set) var isRefreshing = false + @Published private(set) var state: InstallState = .unknown + @Published private(set) var isRefreshing = false private var refreshTask: Task? diff --git a/TablePro/Core/AI/Copilot/CopilotService.swift b/TablePro/Core/AI/Copilot/CopilotService.swift index 76ec7cf2ac..3d9a0babad 100644 --- a/TablePro/Core/AI/Copilot/CopilotService.swift +++ b/TablePro/Core/AI/Copilot/CopilotService.swift @@ -3,11 +3,12 @@ // TablePro // +import Combine import Foundation import os -@MainActor @Observable -final class CopilotService { +@MainActor +final class CopilotService: ObservableObject { nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "CopilotService") static let shared = CopilotService() @@ -29,17 +30,17 @@ final class CopilotService { } } - private(set) var status: Status = .stopped - private(set) var authState: AuthState = .signedOut - private(set) var statusMessage: String? - - @ObservationIgnored private var lspClient: LSPClient? - @ObservationIgnored private var transport: LSPTransport? - @ObservationIgnored private var serverGeneration: Int = 0 - @ObservationIgnored private var restartTask: Task? - @ObservationIgnored private var restartAttempt: Int = 0 - @ObservationIgnored private let authManager = CopilotAuthManager() - @ObservationIgnored private lazy var unauthenticatedStop = CopilotIdleStopController( + @Published private(set) var status: Status = .stopped + @Published private(set) var authState: AuthState = .signedOut + @Published private(set) var statusMessage: String? + + private var lspClient: LSPClient? + private var transport: LSPTransport? + private var serverGeneration: Int = 0 + private var restartTask: Task? + private var restartAttempt: Int = 0 + private let authManager = CopilotAuthManager() + private lazy var unauthenticatedStop = CopilotIdleStopController( timeout: Self.unauthenticatedTimeout, isAuthenticated: { [weak self] in self?.isAuthenticated ?? true }, isRunning: { [weak self] in self?.status == .running }, diff --git a/TablePro/Core/AI/Cursor/CursorAgentService.swift b/TablePro/Core/AI/Cursor/CursorAgentService.swift index ff38dec152..652555d7d4 100644 --- a/TablePro/Core/AI/Cursor/CursorAgentService.swift +++ b/TablePro/Core/AI/Cursor/CursorAgentService.swift @@ -3,11 +3,12 @@ // TablePro // +import Combine import Foundation import os -@MainActor @Observable -final class CursorAgentService { +@MainActor +final class CursorAgentService: ObservableObject { static let shared = CursorAgentService() nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "CursorAgentService") @@ -24,11 +25,11 @@ final class CursorAgentService { } } - private(set) var authState: AuthState = .signedOut - private(set) var errorMessage: String? + @Published private(set) var authState: AuthState = .signedOut + @Published private(set) var errorMessage: String? - @ObservationIgnored private let cli: CursorAgentCLI - @ObservationIgnored private var signInTask: Task? + private let cli: CursorAgentCLI + private var signInTask: Task? init(cli: CursorAgentCLI = CursorAgentCLI()) { self.cli = cli diff --git a/TablePro/Core/AI/XAI/XAIService.swift b/TablePro/Core/AI/XAI/XAIService.swift index b604a0d4b0..360057edf3 100644 --- a/TablePro/Core/AI/XAI/XAIService.swift +++ b/TablePro/Core/AI/XAI/XAIService.swift @@ -4,11 +4,12 @@ // import AppKit +import Combine import Foundation import os -@MainActor @Observable -final class XAIService { +@MainActor +final class XAIService: ObservableObject { static let shared = XAIService() nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "XAIService") @@ -24,11 +25,11 @@ final class XAIService { } } - private(set) var authState: AuthState = .signedOut - private(set) var errorMessage: String? + @Published private(set) var authState: AuthState = .signedOut + @Published private(set) var errorMessage: String? - @ObservationIgnored private let tokenStore: XAITokenStore - @ObservationIgnored private let oauthClient: XAIOAuthClient + private let tokenStore: XAITokenStore + private let oauthClient: XAIOAuthClient init( tokenStore: XAITokenStore = .shared, diff --git a/TablePro/Core/Autocomplete/QueryCompletionProfileRegistry.swift b/TablePro/Core/Autocomplete/QueryCompletionProfileRegistry.swift index 4eccb973be..c9e924da28 100644 --- a/TablePro/Core/Autocomplete/QueryCompletionProfileRegistry.swift +++ b/TablePro/Core/Autocomplete/QueryCompletionProfileRegistry.swift @@ -1,5 +1,5 @@ +import Combine import Foundation -import Observation import TableProPluginKit /// One scope's invalidation counter, as its own observable object. @@ -15,9 +15,8 @@ import TableProPluginKit /// A non-observable container holding observable leaves gives real per-scope granularity, which is /// the shape `SchemaProviderRegistry` already uses for its providers. @MainActor -@Observable -final class QueryCompletionRevisionBox { - private(set) var revision = 0 +final class QueryCompletionRevisionBox: ObservableObject { + @Published private(set) var revision = 0 func bump() { revision &+= 1 @@ -29,7 +28,7 @@ final class QueryCompletionRevisionBox { /// Deliberately not `@Observable`: everything a view observes here is a `QueryCompletionRevisionBox`, /// for the reason written on that type. @MainActor -final class QueryCompletionProfileRegistry: CatalogChangeTarget { +final class QueryCompletionProfileRegistry: ObservableObject, CatalogChangeTarget { struct CacheKey: Hashable { let scope: DatabaseScope let databaseType: DatabaseType @@ -37,10 +36,10 @@ final class QueryCompletionProfileRegistry: CatalogChangeTarget { static let shared = QueryCompletionProfileRegistry() - private var profiles: [CacheKey: QueryCompletionProfile] = [:] - private var inFlight: [CacheKey: Task] = [:] - private var generations: [CacheKey: Int] = [:] - private var revisionBoxes: [DatabaseScope: QueryCompletionRevisionBox] = [:] + @Published private var profiles: [CacheKey: QueryCompletionProfile] = [:] + @Published private var inFlight: [CacheKey: Task] = [:] + @Published private var generations: [CacheKey: Int] = [:] + @Published private var revisionBoxes: [DatabaseScope: QueryCompletionRevisionBox] = [:] #if DEBUG /// Test-only init for `@testable` tests in DEBUG builds; release builds must use `.shared`. diff --git a/TablePro/Core/ChangeTracking/AnyChangeManager.swift b/TablePro/Core/ChangeTracking/AnyChangeManager.swift index 5f41912018..7154d29a8e 100644 --- a/TablePro/Core/ChangeTracking/AnyChangeManager.swift +++ b/TablePro/Core/ChangeTracking/AnyChangeManager.swift @@ -1,5 +1,5 @@ +import Combine import Foundation -import Observation import TableProPluginKit @MainActor @@ -28,10 +28,9 @@ extension ChangeManaging { var generatedColumns: Set { [] } } -@Observable @MainActor -final class AnyChangeManager { - @ObservationIgnored private let wrapped: any ChangeManaging +final class AnyChangeManager: ObservableObject { + private let wrapped: any ChangeManaging var hasChanges: Bool { wrapped.hasChanges } var reloadVersion: Int { wrapped.reloadVersion } diff --git a/TablePro/Core/ChangeTracking/DataChangeManager.swift b/TablePro/Core/ChangeTracking/DataChangeManager.swift index 195bc3972d..6b81cc5f21 100644 --- a/TablePro/Core/ChangeTracking/DataChangeManager.swift +++ b/TablePro/Core/ChangeTracking/DataChangeManager.swift @@ -7,8 +7,8 @@ // Uses Apple's UndoManager (NSUndoManager) for undo/redo stack management. // +import Combine import Foundation -import Observation import os import TableProPluginKit @@ -37,37 +37,37 @@ struct UndoResult { /// Manager for tracking and applying data changes /// @MainActor ensures thread-safe access - critical for avoiding EXC_BAD_ACCESS /// when multiple queries complete simultaneously (e.g., rapid sorting over SSH tunnel) -@MainActor @Observable -final class DataChangeManager: ChangeManaging { +@MainActor +final class DataChangeManager: ObservableObject, ChangeManaging { nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "DataChangeManager") - private(set) var pending = PendingChanges() - var hasChanges: Bool = false - var reloadVersion: Int = 0 + @Published private(set) var pending = PendingChanges() + @Published var hasChanges: Bool = false + @Published var reloadVersion: Int = 0 var changes: [RowChange] { pending.changes } var rowChanges: [RowChange] { pending.changes } var insertedRowIDs: Set { pending.insertedRowIDs } var deletedRowIDs: Set { pending.deletedRowIDs } - var tableName: String = "" - var schemaName: String? - var primaryKeyColumns: [String] = [] + @Published var tableName: String = "" + @Published var schemaName: String? + @Published var primaryKeyColumns: [String] = [] /// First PK column, for contexts that need a single column (paste, filters) var primaryKeyColumn: String? { primaryKeyColumns.first } /// Columns the server computes. They reject any written value, so they are /// never editable and never appear in a generated INSERT or UPDATE. - var generatedColumns: Set = [] - private(set) var rowMatchPolicy: RowMatchPolicy = .none - var databaseType: DatabaseType? - var pluginDriver: (any PluginDatabaseDriver)? + @Published var generatedColumns: Set = [] + @Published private(set) var rowMatchPolicy: RowMatchPolicy = .none + @Published var databaseType: DatabaseType? + @Published var pluginDriver: (any PluginDatabaseDriver)? - var columns: [String] = [] + @Published var columns: [String] = [] - var undoManagerProvider: (() -> UndoManager?)? - var onUndoApplied: ((UndoResult) -> Void)? + @Published var undoManagerProvider: (() -> UndoManager?)? + @Published var onUndoApplied: ((UndoResult) -> Void)? - private var lastUndoResult: UndoResult? + @Published private var lastUndoResult: UndoResult? /// The cells an open editor is still typing into, held back from the undo stack until the run /// ends so a typed word is one step rather than one per character. diff --git a/TablePro/Core/Compare/CompareSyncSession.swift b/TablePro/Core/Compare/CompareSyncSession.swift index 90f74dcab1..6b481bf698 100644 --- a/TablePro/Core/Compare/CompareSyncSession.swift +++ b/TablePro/Core/Compare/CompareSyncSession.swift @@ -11,8 +11,8 @@ // comparison is one click rather than walking backwards through a wizard. // +import Combine import Foundation -import Observation import os internal enum CompareSyncActivity: Equatable { @@ -44,64 +44,63 @@ internal enum CompareDetailPane: String, CaseIterable, Hashable { } @MainActor -@Observable -internal final class CompareSyncSession { +internal final class CompareSyncSession: ObservableObject { nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "CompareSyncSession") // MARK: - Setup - internal var mode: CompareSyncMode = .structure - internal var source: DatabaseEndpoint? - internal var target: DatabaseEndpoint? - internal var structureOptions = StructureCompareOptions.default - internal var dataOptions = DataCompareOptions.default - internal var executionSettings = CompareSyncExecutionSettings() - internal var includedKinds: Set = [.table] + @Published internal var mode: CompareSyncMode = .structure + @Published internal var source: DatabaseEndpoint? + @Published internal var target: DatabaseEndpoint? + @Published internal var structureOptions = StructureCompareOptions.default + @Published internal var dataOptions = DataCompareOptions.default + @Published internal var executionSettings = CompareSyncExecutionSettings() + @Published internal var includedKinds: Set = [.table] // MARK: - Results - internal var report: CompareReport? - internal var dataPlans: [DataComparePlan] = [] + @Published internal var report: CompareReport? + @Published internal var dataPlans: [DataComparePlan] = [] /// True once the table list has been read for this pair, which an empty `dataPlans` cannot say /// on its own. Without it "not read yet" and "these two share no table" are the same state, and /// the pane claims the second whenever the first is true. - internal var hasLoadedDataPlans = false + @Published internal var hasLoadedDataPlans = false /// Tables that were listed on one side and whose metadata could not be read. An empty plan list /// with unreadable tables behind it is not "these two share no table", and saying so sent the /// reader looking for a naming difference that was not there. - internal var unreadableTableCount = 0 - internal var actions: [String: TableSyncAction] = [:] - internal var statements: [SyncStatement] = [] - internal var runResult: CompareSyncRunResult? - internal var sourceSnapshots: [String: TableStructureSnapshot] = [:] - internal var targetSnapshots: [String: TableStructureSnapshot] = [:] + @Published internal var unreadableTableCount = 0 + @Published internal var actions: [String: TableSyncAction] = [:] + @Published internal var statements: [SyncStatement] = [] + @Published internal var runResult: CompareSyncRunResult? + @Published internal var sourceSnapshots: [String: TableStructureSnapshot] = [:] + @Published internal var targetSnapshots: [String: TableStructureSnapshot] = [:] // MARK: - Presentation - internal var selectedObjectId: String? - internal var selectedPlanId: String? - internal var detailPane: CompareDetailPane = .definitions - internal var searchText = "" - internal var showsIdentical = false - internal var grouping: CompareGrouping = .byDifference + @Published internal var selectedObjectId: String? + @Published internal var selectedPlanId: String? + @Published internal var detailPane: CompareDetailPane = .definitions + @Published internal var searchText = "" + @Published internal var showsIdentical = false + @Published internal var grouping: CompareGrouping = .byDifference // MARK: - Activity - internal var activity: CompareSyncActivity = .idle - internal var errorMessage: String? - internal var informationalMessage: String? - internal var lastAction: CompareSyncLastAction = .none - internal var progress: Progress? - internal var hasWrittenToTarget = false + @Published internal var activity: CompareSyncActivity = .idle + @Published internal var errorMessage: String? + @Published internal var informationalMessage: String? + @Published internal var lastAction: CompareSyncLastAction = .none + @Published internal var progress: Progress? + @Published internal var hasWrittenToTarget = false /// True once a script has run against the target, until the next comparison. - internal var isStaleAfterApply = false + @Published internal var isStaleAfterApply = false internal var runTask: Task? - internal var pendingSelection: Set = [] - internal var pendingTableScopes: [String: DataTableScope] = [:] - internal var pendingLegacyExcludedColumns: Set = [] + @Published internal var pendingSelection: Set = [] + @Published internal var pendingTableScopes: [String: DataTableScope] = [:] + @Published internal var pendingLegacyExcludedColumns: Set = [] /// Which setup the answers on screen belong to. /// @@ -110,7 +109,7 @@ internal final class CompareSyncSession { /// answer. Publishing that answer puts one pair's plans, snapshots or statements behind another /// pair's Compare and Apply, which is the same trap `ConnectionAttemptRegistry` exists for on /// the connection side. Every async publisher captures this and drops its result if it moved. - private(set) var setupGeneration = 0 + @Published private(set) var setupGeneration = 0 /// Which set of choices the script on screen was built from. /// @@ -119,7 +118,7 @@ internal final class CompareSyncSession { /// those controls stay live while a build is in flight. A build that finished after one of them /// would republish statements for an object the user had just excluded, and Apply would then /// open on them, because an ordinary INSERT or ALTER is not a hazard `runRefusalReason` catches. - private(set) var scriptRevision = 0 + @Published private(set) var scriptRevision = 0 /// Which question the answers on screen were computed for. /// @@ -128,19 +127,19 @@ internal final class CompareSyncSession { /// Ticking a table or excluding a row is the other half of the same distinction. It changes /// which statements come out of an answer that still stands, so it invalidates the script and /// must not throw away a comparison that has been streaming rows for minutes. - private(set) var answerRevision = 0 + @Published private(set) var answerRevision = 0 /// A setup problem, which outlives the comparison it interrupted. `errorMessage` is cleared by /// the next reset, and a reset is exactly what changing the setup does, so a message about the /// setup itself cannot live there: loading a profile whose connection is gone reported the /// failure and had it wiped by the option change the same load caused. - internal var setupErrorMessage: String? + @Published internal var setupErrorMessage: String? /// The two things the setup needs from outside the session: where a saved comparison lives, and /// which connections a stored scope can resolve against. Injected rather than reached for, so /// the restore rules can be exercised without writing to the user's own defaults. - @ObservationIgnored internal let profileStorage: CompareSyncProfileStorage - @ObservationIgnored internal let connectionsProvider: @MainActor () -> [DatabaseConnection] + internal let profileStorage: CompareSyncProfileStorage + internal let connectionsProvider: @MainActor () -> [DatabaseConnection] internal init( profileStorage: CompareSyncProfileStorage = .shared, diff --git a/TablePro/Core/Coordinators/FilterCoordinator.swift b/TablePro/Core/Coordinators/FilterCoordinator.swift index c3b41d8ba7..aa97ec2a35 100644 --- a/TablePro/Core/Coordinators/FilterCoordinator.swift +++ b/TablePro/Core/Coordinators/FilterCoordinator.swift @@ -3,12 +3,13 @@ // TablePro // +import Combine import Foundation import SwiftUI -@MainActor @Observable -final class FilterCoordinator { - @ObservationIgnored unowned let parent: MainContentCoordinator +@MainActor +final class FilterCoordinator: ObservableObject { + unowned let parent: MainContentCoordinator init(parent: MainContentCoordinator) { self.parent = parent diff --git a/TablePro/Core/Coordinators/FindCoordinator.swift b/TablePro/Core/Coordinators/FindCoordinator.swift index ed38411f42..b100f0c842 100644 --- a/TablePro/Core/Coordinators/FindCoordinator.swift +++ b/TablePro/Core/Coordinators/FindCoordinator.swift @@ -3,13 +3,14 @@ // TablePro // +import Combine import Foundation import SwiftUI -@MainActor @Observable -final class FindCoordinator { - @ObservationIgnored unowned let parent: MainContentCoordinator - @ObservationIgnored private var searchDebounce: DispatchWorkItem? +@MainActor +final class FindCoordinator: ObservableObject { + unowned let parent: MainContentCoordinator + private var searchDebounce: DispatchWorkItem? init(parent: MainContentCoordinator) { self.parent = parent diff --git a/TablePro/Core/Coordinators/PaginationCoordinator.swift b/TablePro/Core/Coordinators/PaginationCoordinator.swift index f41a748f37..0952f6b060 100644 --- a/TablePro/Core/Coordinators/PaginationCoordinator.swift +++ b/TablePro/Core/Coordinators/PaginationCoordinator.swift @@ -4,15 +4,16 @@ // import AppKit +import Combine import Foundation import os import TableProPluginKit private let progressLog = Logger(subsystem: "com.TablePro", category: "ProgressiveLoad") -@MainActor @Observable -final class PaginationCoordinator { - @ObservationIgnored unowned let parent: MainContentCoordinator +@MainActor +final class PaginationCoordinator: ObservableObject { + unowned let parent: MainContentCoordinator init(parent: MainContentCoordinator) { self.parent = parent diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator.swift index 98d3ecb40a..524a5a2aea 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator.swift @@ -3,11 +3,12 @@ // TablePro // +import Combine import Foundation -@MainActor @Observable -final class QueryExecutionCoordinator { - @ObservationIgnored unowned let parent: MainContentCoordinator +@MainActor +final class QueryExecutionCoordinator: ObservableObject { + unowned let parent: MainContentCoordinator init(parent: MainContentCoordinator) { self.parent = parent diff --git a/TablePro/Core/Coordinators/RowEditingCoordinator.swift b/TablePro/Core/Coordinators/RowEditingCoordinator.swift index 04d1fbb10a..8a46b7114e 100644 --- a/TablePro/Core/Coordinators/RowEditingCoordinator.swift +++ b/TablePro/Core/Coordinators/RowEditingCoordinator.swift @@ -3,19 +3,20 @@ // TablePro // +import Combine import Foundation import TableProPluginKit -@MainActor @Observable -final class RowEditingCoordinator { - @ObservationIgnored unowned let parent: MainContentCoordinator +@MainActor +final class RowEditingCoordinator: ObservableObject { + unowned let parent: MainContentCoordinator /// A save is between assembling its statements and hearing back. /// /// Nothing clears the pending changes until the write returns, so a second Cmd+S inside the /// round trip finds them still there, assembles the same statements again and commits them /// twice. Over a slow link that is easy to do by accident. - @ObservationIgnored private(set) var isSaveInFlight = false + private(set) var isSaveInFlight = false init(parent: MainContentCoordinator) { self.parent = parent diff --git a/TablePro/Core/Database/AWS/Discovery/AWSDiscoverySession.swift b/TablePro/Core/Database/AWS/Discovery/AWSDiscoverySession.swift index 332230d13a..6cc483cb8b 100644 --- a/TablePro/Core/Database/AWS/Discovery/AWSDiscoverySession.swift +++ b/TablePro/Core/Database/AWS/Discovery/AWSDiscoverySession.swift @@ -1,10 +1,10 @@ +import Combine import Foundation import os import TableProPluginKit @MainActor -@Observable -final class AWSDiscoverySession { +final class AWSDiscoverySession: ObservableObject { enum RegionProgress: Equatable, Sendable { case pending case loading @@ -21,26 +21,26 @@ final class AWSDiscoverySession { private static let logger = Logger(subsystem: "com.TablePro", category: "AWSDiscovery") - var profileName: String { + @Published var profileName: String { didSet { guard profileName != oldValue else { return } refreshProfileMetadata() } } - var selectedRegionIds: [String] = [] - var authenticationMode: AWSDiscoveryAuthentication.Mode = .iam - private(set) var profileKind: AWSProfileKind = .unknown + @Published var selectedRegionIds: [String] = [] + @Published var authenticationMode: AWSDiscoveryAuthentication.Mode = .iam + @Published private(set) var profileKind: AWSProfileKind = .unknown - private(set) var isRunning = false - private(set) var isSigningIn = false - private(set) var regionProgress: [String: RegionProgress] = [:] - private(set) var databases: [DiscoveredDatabase] = [] - private(set) var credentialFailure: CredentialFailure? + @Published private(set) var isRunning = false + @Published private(set) var isSigningIn = false + @Published private(set) var regionProgress: [String: RegionProgress] = [:] + @Published private(set) var databases: [DiscoveredDatabase] = [] + @Published private(set) var credentialFailure: CredentialFailure? /// A cancelled run cannot be interrupted while `credential_process` blocks in its own /// process, so the UI is released by generation and the late completion discards itself. - private var runGeneration = 0 + @Published private var runGeneration = 0 init(profileName: String = "") { self.profileName = profileName diff --git a/TablePro/Core/Database/DatabaseManager.swift b/TablePro/Core/Database/DatabaseManager.swift index 046ac27de1..aa85b0abf9 100644 --- a/TablePro/Core/Database/DatabaseManager.swift +++ b/TablePro/Core/Database/DatabaseManager.swift @@ -7,26 +7,25 @@ import Combine import Foundation -import Observation import os import TableProPluginKit /// Manages database connections and active drivers -@MainActor @Observable -final class DatabaseManager { +@MainActor +final class DatabaseManager: ObservableObject { static let shared = DatabaseManager() nonisolated internal static let logger = Logger(subsystem: "com.TablePro", category: "DatabaseManager") - @ObservationIgnored internal let connectionStorage: ConnectionStorage - @ObservationIgnored internal let appSettingsStorage: AppSettingsStorage - @ObservationIgnored internal let pluginManager: PluginManager - @ObservationIgnored internal var historyRecorder: QueryHistoryRecording = QueryHistoryManager.shared + internal let connectionStorage: ConnectionStorage + internal let appSettingsStorage: AppSettingsStorage + internal let pluginManager: PluginManager + internal var historyRecorder: QueryHistoryRecording = QueryHistoryManager.shared /// Passwords the user has been asked for this launch, keyed by whatever answers for them: the /// credential profile when the connection links to one, the connection otherwise. Held here /// rather than on the session because a profile's answer belongs to every connection using it, /// and one of them being disconnected does not make it wrong for the rest. - @ObservationIgnored internal var promptedPasswords: [UUID: String] = [:] + internal var promptedPasswords: [UUID: String] = [:] /// All active connection sessions internal(set) var activeSessions: [UUID: ConnectionSession] = [:] { @@ -54,17 +53,17 @@ final class DatabaseManager { /// contentless window or a file opened from Finder. Never resolve the target of an /// operation through it: read the connection id from the window or tab that owns the /// operation instead. - internal var lastActiveSessionId: UUID? + @Published internal var lastActiveSessionId: UUID? /// Health monitors for active connections (MySQL/PostgreSQL only) - @ObservationIgnored internal var healthMonitors: [UUID: ConnectionHealthMonitor] = [:] + internal var healthMonitors: [UUID: ConnectionHealthMonitor] = [:] /// Tracks connections with user queries currently in-flight. /// The health monitor skips pings while a query is running to avoid /// racing on non-thread-safe driver connections. - @ObservationIgnored internal var queriesInFlight: [UUID: Int] = [:] + internal var queriesInFlight: [UUID: Int] = [:] /// Tracks when the first query started for each session (used for staleness detection). - @ObservationIgnored internal var queryStartTimes: [UUID: Date] = [:] + internal var queryStartTimes: [UUID: Date] = [:] /// When each connection's server last answered, whether that was the connect itself, a health /// check, or a check made because the user was about to use it. @@ -74,46 +73,46 @@ final class DatabaseManager { /// unwritable in practice: `updateSession` discards a write that leaves /// `isContentViewEquivalent` unchanged, which is exactly a timestamp-only write, and going /// around that through `setSession` broadcasts a status change nothing happened to. - @ObservationIgnored internal var lastVerifiedAt: [UUID: Date] = [:] + internal var lastVerifiedAt: [UUID: Date] = [:] /// Collapses concurrent verifications of one connection into a single check, so a window /// waking up with several tabs pointed at the same connection asks once. Separate from /// `ensureConnectedDedup` because a verification can run while a connect is in flight. - @ObservationIgnored internal let verificationDedup = OnceTask() + internal let verificationDedup = OnceTask() /// Connection IDs currently undergoing SSH tunnel recovery. /// Prevents duplicate concurrent recovery when both the keepalive death handler /// and the wake-from-sleep handler fire for the same connection. - @ObservationIgnored internal var recoveringConnectionIds = Set() + internal var recoveringConnectionIds = Set() /// Why a session was torn down, kept past the session entry so a window that only observes /// the entry disappearing can still name the cause. Cleared when a fresh attempt begins. - @ObservationIgnored internal var disconnectReasons: [UUID: ConnectionEndReason] = [:] + internal var disconnectReasons: [UUID: ConnectionEndReason] = [:] /// Connections the user disconnected on purpose. Kept past the session entry for the same /// reason `disconnectReasons` is: the window learns the session went away by watching the /// entry disappear, and a deliberate disconnect is not the same event as losing a connection. - @ObservationIgnored internal var userRequestedDisconnects = Set() + internal var userRequestedDisconnects = Set() /// Sessions currently being torn down, so a second disconnect cannot run the teardown again and /// finish it against a session the user has since reconnected. - @ObservationIgnored internal var disconnectsInFlight = Set() + internal var disconnectsInFlight = Set() /// Installed at launch. Every disconnect writes the connection's tabs to disk through this /// before the session entry goes away, because the window can outlive the session. - @ObservationIgnored internal var tabStatePersister: (any SessionTabStatePersisting)? + internal var tabStatePersister: (any SessionTabStatePersisting)? - @ObservationIgnored internal var connectionUpdatedCancellable: AnyCancellable? - @ObservationIgnored internal var healthCheckSettingCancellable: AnyCancellable? + internal var connectionUpdatedCancellable: AnyCancellable? + internal var healthCheckSettingCancellable: AnyCancellable? /// The tail of the serialized monitor restarts. See `observeHealthCheckSetting`. - @ObservationIgnored internal var healthMonitorRestart: Task? + internal var healthMonitorRestart: Task? - @ObservationIgnored internal let ensureConnectedDedup = OnceTask() + internal let ensureConnectedDedup = OnceTask() /// Generation token per connection. A cancelled or superseded attempt keeps running /// when its driver blocks in a C call, so every attempt validates its generation /// before touching shared session state and discards its driver when it lost. - @ObservationIgnored internal var connectionAttempts = ConnectionAttemptRegistry() + internal var connectionAttempts = ConnectionAttemptRegistry() /// The step each in-flight connect last reported, so a window that joins one already running /// can seed itself. `AppEvents.connectionStageChanged` is a `PassthroughSubject`, so it holds @@ -121,16 +120,16 @@ final class DatabaseManager { /// which is how a connection dialling through an SSH jump host announced itself as "Opening /// the connection" for the whole of the tunnel handshake. Written only by the current attempt, /// for the same reason every other shared write here is generation-checked. - @ObservationIgnored internal var connectionStages: [UUID: ConnectionStage] = [:] + internal var connectionStages: [UUID: ConnectionStage] = [:] /// Orders operations that move the shared driver, so two windows cannot interleave /// their pins and each run against the other's database. - @ObservationIgnored internal let sessionDriverGate = SessionDriverGate() + internal let sessionDriverGate = SessionDriverGate() /// The drivers each connection is currently executing user SQL on, keyed by an /// operation token so a finishing operation can only release its own handle. Stop /// reaches the right one even when a cross-database tab runs on a pooled connection. - @ObservationIgnored internal var runningDrivers: [UUID: [UUID: RunningDriver]] = [:] + internal var runningDrivers: [UUID: [UUID: RunningDriver]] = [:] /// Session for `lastActiveSessionId`, subject to the same caveats. var lastActiveSession: ConnectionSession? { diff --git a/TablePro/Core/Database/NativeDumpBatch.swift b/TablePro/Core/Database/NativeDumpBatch.swift index bdd95d309d..d3e18d871d 100644 --- a/TablePro/Core/Database/NativeDumpBatch.swift +++ b/TablePro/Core/Database/NativeDumpBatch.swift @@ -3,8 +3,8 @@ // TablePro // +import Combine import Foundation -import Observation import os /// One database's share of a backup. @@ -78,16 +78,15 @@ struct NativeDumpBatchState: Equatable { /// because the second was unreadable helps nobody. Every item's outcome is reported, so a partial /// run never looks like a whole one. @MainActor -@Observable -final class NativeDumpBatch { +final class NativeDumpBatch: ObservableObject { nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "NativeDumpBatch") - private(set) var state = NativeDumpBatchState() + @Published private(set) var state = NativeDumpBatchState() - @ObservationIgnored private let makeService: @MainActor () -> any NativeDumpRunning - @ObservationIgnored private let estimateSize: @MainActor (DatabaseConnection, String) async -> Int64? - @ObservationIgnored private var current: (any NativeDumpRunning)? - @ObservationIgnored private var cancelled = false + private let makeService: @MainActor () -> any NativeDumpRunning + private let estimateSize: @MainActor (DatabaseConnection, String) async -> Int64? + private var current: (any NativeDumpRunning)? + private var cancelled = false init( makeService: @escaping @MainActor () -> any NativeDumpRunning = { NativeDumpService(kind: .backup) }, diff --git a/TablePro/Core/Database/NativeDumpService.swift b/TablePro/Core/Database/NativeDumpService.swift index d55e1a5c4d..490e4d1f22 100644 --- a/TablePro/Core/Database/NativeDumpService.swift +++ b/TablePro/Core/Database/NativeDumpService.swift @@ -8,8 +8,8 @@ // progress, cancel and result handling that spawning a process already had. // +import Combine import Foundation -import Observation import os import TableProPluginKit @@ -173,18 +173,17 @@ protocol NativeDumpRunner: AnyObject { // MARK: - Service @MainActor -@Observable -final class NativeDumpService { +final class NativeDumpService: ObservableObject { nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "NativeDumpService") let kind: NativeDumpKind - private(set) var state: NativeDumpState = .idle + @Published private(set) var state: NativeDumpState = .idle - @ObservationIgnored private let runnerFactory: @MainActor (NativeDumpJob) -> any NativeDumpRunner - @ObservationIgnored private var runner: (any NativeDumpRunner)? - @ObservationIgnored private var byteSizeTask: Task? - @ObservationIgnored private var stateObservers: [UUID: AsyncStream.Continuation] = [:] - @ObservationIgnored private var toolName = "dump" + private let runnerFactory: @MainActor (NativeDumpJob) -> any NativeDumpRunner + private var runner: (any NativeDumpRunner)? + private var byteSizeTask: Task? + private var stateObservers: [UUID: AsyncStream.Continuation] = [:] + private var toolName = "dump" func stateUpdates() -> AsyncStream { let (stream, continuation) = AsyncStream.makeStream() diff --git a/TablePro/Core/MCP/Lifecycle/MCPServerManager.swift b/TablePro/Core/MCP/Lifecycle/MCPServerManager.swift index e06678396e..b7cbae54eb 100644 --- a/TablePro/Core/MCP/Lifecycle/MCPServerManager.swift +++ b/TablePro/Core/MCP/Lifecycle/MCPServerManager.swift @@ -1,3 +1,4 @@ +import Combine import Foundation import os @@ -8,8 +9,8 @@ internal enum MCPServerState: Sendable, Equatable { case failed(String) } -@MainActor @Observable -internal final class MCPServerManager { +@MainActor +internal final class MCPServerManager: ObservableObject { internal struct SessionSnapshot: Sendable, Identifiable { internal let id: String internal let clientName: String @@ -33,22 +34,22 @@ internal final class MCPServerManager { internal static let shared = MCPServerManager() - internal private(set) var state: MCPServerState = .stopped - internal private(set) var connectedClients: [SessionSnapshot] = [] - internal private(set) var tokenStore: MCPTokenStore? + @Published internal private(set) var state: MCPServerState = .stopped + @Published internal private(set) var connectedClients: [SessionSnapshot] = [] + @Published internal private(set) var tokenStore: MCPTokenStore? private let lifecycle = MCPServerLifecycleQueue() private let handshake: MCPHandshakeFile - private var composition: MCPServerComposition? - private var bridgeCredential: BridgeCredential? - private var instanceId = "" - private var generation = 0 - private var revocationObserverId: UUID? - private var dispatchTask: Task? - private var stateTask: Task? - private var clientRefreshTask: Task? - private var tokenRenewalTask: Task? + @Published private var composition: MCPServerComposition? + @Published private var bridgeCredential: BridgeCredential? + @Published private var instanceId = "" + @Published private var generation = 0 + @Published private var revocationObserverId: UUID? + @Published private var dispatchTask: Task? + @Published private var stateTask: Task? + @Published private var clientRefreshTask: Task? + @Published private var tokenRenewalTask: Task? internal var isRunning: Bool { if case .running = state { return true } diff --git a/TablePro/Core/MCP/Subscriptions/MCPSubscriptionEventSource.swift b/TablePro/Core/MCP/Subscriptions/MCPSubscriptionEventSource.swift index 81703df333..0bf216852e 100644 --- a/TablePro/Core/MCP/Subscriptions/MCPSubscriptionEventSource.swift +++ b/TablePro/Core/MCP/Subscriptions/MCPSubscriptionEventSource.swift @@ -4,6 +4,7 @@ import os @MainActor public final class MCPSubscriptionEventSource { + private var schemaObservation: AnyCancellable? nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "MCP.Subscriptions") private weak var registry: MCPSubscriptionRegistry? @@ -56,14 +57,9 @@ public final class MCPSubscriptionEventSource { guard isObserving else { return } observationGeneration &+= 1 let generation = observationGeneration - withObservationTracking { - _ = SchemaService.shared.generations - } onChange: { [weak self] in - Task { @MainActor [weak self] in - guard let self, self.isObserving, generation == self.observationGeneration else { return } - self.armSchemaObservation() - self.publishSchemaUpdates() - } + schemaObservation = SchemaService.shared.onMainActorChange { [weak self] in + guard let self, self.isObserving, generation == self.observationGeneration else { return } + self.publishSchemaUpdates() } } diff --git a/TablePro/Core/ObjectCopy/ObjectCopySession.swift b/TablePro/Core/ObjectCopy/ObjectCopySession.swift index 72d23a256f..9b3f97583e 100644 --- a/TablePro/Core/ObjectCopy/ObjectCopySession.swift +++ b/TablePro/Core/ObjectCopy/ObjectCopySession.swift @@ -13,8 +13,8 @@ // both databases. Configuring is free; reviewing costs one round of reads. // +import Combine import Foundation -import Observation import os import TableProPluginKit @@ -44,8 +44,7 @@ internal enum ObjectCopyFormState: Hashable { } @MainActor -@Observable -internal final class ObjectCopySession { +internal final class ObjectCopySession: ObservableObject { nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "ObjectCopySession") // MARK: - Fixed at launch @@ -56,41 +55,41 @@ internal final class ObjectCopySession { // MARK: - Choices - internal var target: DatabaseEndpoint? - internal var newDatabaseName = "" - internal var newDatabaseValues: [String: String] = [:] - internal var createDatabaseForm: CreateDatabaseFormSpec? - internal var createDatabaseFormState: ObjectCopyFormState = .loading - internal var content: ObjectCopyContent = .structureAndData - internal var existingPolicy: ObjectCopyExistingPolicy = .skip - internal var errorHandling: ImportErrorHandling = .stopAndRollback - internal var searchText = "" + @Published internal var target: DatabaseEndpoint? + @Published internal var newDatabaseName = "" + @Published internal var newDatabaseValues: [String: String] = [:] + @Published internal var createDatabaseForm: CreateDatabaseFormSpec? + @Published internal var createDatabaseFormState: ObjectCopyFormState = .loading + @Published internal var content: ObjectCopyContent = .structureAndData + @Published internal var existingPolicy: ObjectCopyExistingPolicy = .skip + @Published internal var errorHandling: ImportErrorHandling = .stopAndRollback + @Published internal var searchText = "" /// A `WHERE` and a row limit per table, keyed by the selection's own id so two overloads or two /// same-named triggers cannot share one. Absent means every row, which is what a copy did /// before this existed. - internal var rowScopes: [String: PluginExportRowScope] = [:] + @Published internal var rowScopes: [String: PluginExportRowScope] = [:] /// The table whose filter popover is open, if any. Held here rather than in the row, because a /// row is rebuilt whenever the search text changes and a popover anchored to `@State` inside /// one closes as soon as the list diffs under a keystroke. - internal var rowFilterObjectId: String? + @Published internal var rowFilterObjectId: String? // MARK: - Catalog - internal var availableObjects: [ObjectCopySelection] = [] - internal var selectedObjectIds: Set = [] - internal var isLoadingObjects = true - internal var catalogError: String? + @Published internal var availableObjects: [ObjectCopySelection] = [] + @Published internal var selectedObjectIds: Set = [] + @Published internal var isLoadingObjects = true + @Published internal var catalogError: String? // MARK: - Run - internal var step: ObjectCopyStep = .configuring - internal var plan: ObjectCopyPlan? - internal var progress: Progress? - internal var copiedRows = 0 - internal var currentObject = "" - internal var result: ObjectCopyRunResult? - internal var errorMessage: String? - @ObservationIgnored internal var runTask: Task? + @Published internal var step: ObjectCopyStep = .configuring + @Published internal var plan: ObjectCopyPlan? + @Published internal var progress: Progress? + @Published internal var copiedRows = 0 + @Published internal var currentObject = "" + @Published internal var result: ObjectCopyRunResult? + @Published internal var errorMessage: String? + internal var runTask: Task? internal init( mode: ObjectCopyMode, @@ -107,7 +106,7 @@ internal final class ObjectCopySession { } } - @ObservationIgnored private let pendingPreselection: [ObjectCopySelection] + private let pendingPreselection: [ObjectCopySelection] // MARK: - Naming @@ -487,7 +486,7 @@ internal final class ObjectCopySession { runTask?.cancel() } - @ObservationIgnored private var observations: [NSKeyValueObservation] = [] + private var observations: [NSKeyValueObservation] = [] private func observe(_ runProgress: Progress) { observations.forEach { $0.invalidate() } diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index f8ed57a5f2..378fd7c89e 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -11,8 +11,8 @@ import Security import SwiftUI import TableProPluginKit -@MainActor @Observable -final class PluginManager { +@MainActor +final class PluginManager: ObservableObject { static let shared = PluginManager(userDefaults: AppStorageEnvironment.shared.defaults) /// Raised to 29 for `maintenanceOperations` on `PluginDatabaseDriver`, plus the /// `PluginMaintenanceOperation`, `PluginMaintenanceOption`, `PluginMaintenanceScope` and @@ -74,9 +74,9 @@ final class PluginManager { private static let disabledPluginsKey = "com.TablePro.disabledPlugins" private static let legacyDisabledPluginsKey = "disabledPlugins" - @ObservationIgnored private let defaults: UserDefaults - @ObservationIgnored private let builtInPluginsURL: URL? - @ObservationIgnored internal let userPluginsDir: URL + private let defaults: UserDefaults + private let builtInPluginsURL: URL? + internal let userPluginsDir: URL internal(set) var plugins: [PluginEntry] = [] @@ -105,7 +105,7 @@ final class PluginManager { } } - @ObservationIgnored private var initialLoadWaiters: [LoadWaiter] = [] + private var initialLoadWaiters: [LoadWaiter] = [] private struct LoadWaiter { let id: UUID @@ -137,7 +137,7 @@ final class PluginManager { internal(set) var rejectedPlugins: [RejectedPlugin] = [] - var needsRestart: Bool = false + @Published var needsRestart: Bool = false internal(set) var driverPlugins: [String: any DriverPlugin] = [:] @@ -156,30 +156,30 @@ final class PluginManager { nonisolated static let logger = Logger(subsystem: "com.TablePro", category: "PluginManager") - private var pendingPluginURLs: [(url: URL, source: PluginSource)] = [] - - @ObservationIgnored private(set) var lazyDriverURLs: [String: URL] = [:] - @ObservationIgnored private var lazyExportURLs: [String: URL] = [:] - @ObservationIgnored private var lazyImportURLs: [String: URL] = [:] - @ObservationIgnored internal var lazyInspectorURLs: [String: URL] = [:] - @ObservationIgnored internal var lazyInspectorFileExtensions: [String: URL] = [:] - @ObservationIgnored internal var lazyInspectorUTIs: [String: URL] = [:] - @ObservationIgnored private var activatedBundleIds: Set = [] - - @ObservationIgnored internal var reconciliationTask: Task? - @ObservationIgnored internal var reconciliationActive = false - @ObservationIgnored internal var reconciliationAttempts: [String: Int] = [:] - @ObservationIgnored internal var reconciliationManifestAttempts = 0 - @ObservationIgnored private var connectionStatusSubscription: AnyCancellable? - @ObservationIgnored internal var pluginNetworkMonitor: NWPathMonitor? - @ObservationIgnored internal var lastNetworkSatisfied = false - @ObservationIgnored internal var installsInFlight: Set = [] + @Published private var pendingPluginURLs: [(url: URL, source: PluginSource)] = [] + + private(set) var lazyDriverURLs: [String: URL] = [:] + private var lazyExportURLs: [String: URL] = [:] + private var lazyImportURLs: [String: URL] = [:] + internal var lazyInspectorURLs: [String: URL] = [:] + internal var lazyInspectorFileExtensions: [String: URL] = [:] + internal var lazyInspectorUTIs: [String: URL] = [:] + private var activatedBundleIds: Set = [] + + internal var reconciliationTask: Task? + internal var reconciliationActive = false + internal var reconciliationAttempts: [String: Int] = [:] + internal var reconciliationManifestAttempts = 0 + private var connectionStatusSubscription: AnyCancellable? + internal var pluginNetworkMonitor: NWPathMonitor? + internal var lastNetworkSatisfied = false + internal var installsInFlight: Set = [] /// User-installed bundles discovered but not yet signature-checked. `sweepPluginSignatures()` /// drains it after the first frame. - @ObservationIgnored internal var pendingSignatureChecks: [URL] = [] + internal var pendingSignatureChecks: [URL] = [] - var queryBuildingDriverCache: [String: (any PluginDatabaseDriver)?] = [:] + @Published var queryBuildingDriverCache: [String: (any PluginDatabaseDriver)?] = [:] init( userDefaults: UserDefaults = AppStorageEnvironment.shared.defaults, diff --git a/TablePro/Core/Plugins/Registry/DownloadCountService.swift b/TablePro/Core/Plugins/Registry/DownloadCountService.swift index fb893a13cc..655ed3113e 100644 --- a/TablePro/Core/Plugins/Registry/DownloadCountService.swift +++ b/TablePro/Core/Plugins/Registry/DownloadCountService.swift @@ -3,15 +3,16 @@ // TablePro // +import Combine import Foundation import os -@MainActor @Observable -final class DownloadCountService { +@MainActor +final class DownloadCountService: ObservableObject { static let shared = DownloadCountService() - private var counts: [String: Int] = [:] - private var lastFetchDate: Date? + @Published private var counts: [String: Int] = [:] + @Published private var lastFetchDate: Date? private static let cooldown: TimeInterval = 300 // 5 minutes nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "DownloadCountService") diff --git a/TablePro/Core/Plugins/Registry/PluginInstallTracker.swift b/TablePro/Core/Plugins/Registry/PluginInstallTracker.swift index afbb1c57c6..88be5ecb8e 100644 --- a/TablePro/Core/Plugins/Registry/PluginInstallTracker.swift +++ b/TablePro/Core/Plugins/Registry/PluginInstallTracker.swift @@ -3,13 +3,14 @@ // TablePro // +import Combine import Foundation -@MainActor @Observable -final class PluginInstallTracker { +@MainActor +final class PluginInstallTracker: ObservableObject { static let shared = PluginInstallTracker() - private(set) var activeInstalls: [String: InstallProgress] = [:] + @Published private(set) var activeInstalls: [String: InstallProgress] = [:] private init() {} diff --git a/TablePro/Core/Plugins/Registry/RegistryClient.swift b/TablePro/Core/Plugins/Registry/RegistryClient.swift index ad0122b914..57b6588ec2 100644 --- a/TablePro/Core/Plugins/Registry/RegistryClient.swift +++ b/TablePro/Core/Plugins/Registry/RegistryClient.swift @@ -3,16 +3,17 @@ // TablePro // +import Combine import Foundation import os -@MainActor @Observable -final class RegistryClient { +@MainActor +final class RegistryClient: ObservableObject { static let shared = RegistryClient() - private(set) var manifest: RegistryManifest? - private(set) var fetchState: RegistryFetchState = .idle - private(set) var lastFetchDate: Date? + @Published private(set) var manifest: RegistryManifest? + @Published private(set) var fetchState: RegistryFetchState = .idle + @Published private(set) var lastFetchDate: Date? let session: URLSession static let supportedSchemaVersion = 2 @@ -32,8 +33,8 @@ final class RegistryClient { private let defaults: UserDefaults private let manifestCacheURL: URL - @ObservationIgnored private var inFlightFetch: Task? - @ObservationIgnored private var lastFetchedURL: URL? + private var inFlightFetch: Task? + private var lastFetchedURL: URL? var isUsingCustomRegistry: Bool { registryURL != Self.defaultRegistryURL diff --git a/TablePro/Core/SOCKS/SOCKSProxyManager.swift b/TablePro/Core/SOCKS/SOCKSProxyManager.swift index f247176779..62ba4267bc 100644 --- a/TablePro/Core/SOCKS/SOCKSProxyManager.swift +++ b/TablePro/Core/SOCKS/SOCKSProxyManager.swift @@ -9,6 +9,7 @@ import os enum SOCKSProxyError: Error, LocalizedError, Equatable { case invalidConfiguration + case unsupportedOnThisSystem case listenerFailed(String) case connectTimedOut(proxyHost: String, proxyPort: Int) case connectFailed(proxyHost: String, proxyPort: Int, underlying: String) @@ -17,6 +18,8 @@ enum SOCKSProxyError: Error, LocalizedError, Equatable { switch self { case .invalidConfiguration: return String(localized: "The SOCKS proxy configuration is incomplete. Enter a proxy host and port.") + case .unsupportedOnThisSystem: + return String(localized: "SOCKS proxy connections need macOS 14 or later. Use an SSH tunnel instead, or update macOS.") case .listenerFailed(let reason): return String(format: String(localized: "Could not open a local port for the SOCKS proxy: %@"), reason) case .connectTimedOut(let proxyHost, let proxyPort): @@ -77,6 +80,11 @@ actor SOCKSProxyManager: TunnelManaging { try await closeTunnel(connectionId: connectionId) } + /// `ProxyConfiguration` is macOS 14. Network.framework offers no SOCKS5 path before + /// it, so the connection is refused with a reason rather than failing obscurely. + guard #available(macOS 14.0, *) else { + throw SOCKSProxyError.unsupportedOnThisSystem + } let privacyContext = Self.makePrivacyContext(connectionId: connectionId, config: config, password: password) try await probeProxyPath(config: config, privacyContext: privacyContext, targetHost: targetHost, targetPort: targetPort) @@ -249,6 +257,7 @@ actor SOCKSProxyManager: TunnelManaging { UInt16(exactly: port).flatMap { $0 > 0 ? NWEndpoint.Port(rawValue: $0) : nil } } + @available(macOS 14.0, *) private static func makePrivacyContext( connectionId: UUID, config: SOCKSProxyConfiguration, diff --git a/TablePro/Core/SchemaTracking/StructureChangeManager.swift b/TablePro/Core/SchemaTracking/StructureChangeManager.swift index ef96735e78..3fdda24be2 100644 --- a/TablePro/Core/SchemaTracking/StructureChangeManager.swift +++ b/TablePro/Core/SchemaTracking/StructureChangeManager.swift @@ -6,34 +6,34 @@ // Mirrors DataChangeManager architecture for schema modifications. // +import Combine import Foundation -import Observation import TableProPluginKit /// Manager for tracking and applying schema changes -@MainActor @Observable -final class StructureChangeManager: ChangeManaging { - private(set) var pendingChanges: [SchemaChangeIdentifier: SchemaChange] = [:] - @ObservationIgnored private var changeOrder: [SchemaChangeIdentifier] = [] - private(set) var validationErrors: [SchemaChangeIdentifier: String] = [:] +@MainActor +final class StructureChangeManager: ObservableObject, ChangeManaging { + @Published private(set) var pendingChanges: [SchemaChangeIdentifier: SchemaChange] = [:] + private var changeOrder: [SchemaChangeIdentifier] = [] + @Published private(set) var validationErrors: [SchemaChangeIdentifier: String] = [:] var hasChanges: Bool { !pendingChanges.isEmpty } - var reloadVersion: Int = 0 + @Published var reloadVersion: Int = 0 // Current state (loaded from database) - private(set) var currentColumns: [EditableColumnDefinition] = [] - private(set) var currentIndexes: [EditableIndexDefinition] = [] - private(set) var currentForeignKeys: [EditableForeignKeyDefinition] = [] - private(set) var currentCheckConstraints: [EditableCheckConstraintDefinition] = [] - private(set) var currentPrimaryKey: [String] = [] + @Published private(set) var currentColumns: [EditableColumnDefinition] = [] + @Published private(set) var currentIndexes: [EditableIndexDefinition] = [] + @Published private(set) var currentForeignKeys: [EditableForeignKeyDefinition] = [] + @Published private(set) var currentCheckConstraints: [EditableCheckConstraintDefinition] = [] + @Published private(set) var currentPrimaryKey: [String] = [] // Working state (includes uncommitted changes + placeholders) - var workingColumns: [EditableColumnDefinition] = [] - var workingIndexes: [EditableIndexDefinition] = [] - var workingForeignKeys: [EditableForeignKeyDefinition] = [] - var workingCheckConstraints: [EditableCheckConstraintDefinition] = [] - var workingPrimaryKey: [String] = [] + @Published var workingColumns: [EditableColumnDefinition] = [] + @Published var workingIndexes: [EditableIndexDefinition] = [] + @Published var workingForeignKeys: [EditableForeignKeyDefinition] = [] + @Published var workingCheckConstraints: [EditableCheckConstraintDefinition] = [] + @Published var workingPrimaryKey: [String] = [] - var tableName: String? + @Published var tableName: String? // MARK: - Undo/Redo Support diff --git a/TablePro/Core/Services/Export/ExportService.swift b/TablePro/Core/Services/Export/ExportService.swift index 2be5e140dd..4801e3b38f 100644 --- a/TablePro/Core/Services/Export/ExportService.swift +++ b/TablePro/Core/Services/Export/ExportService.swift @@ -3,8 +3,8 @@ // TablePro // +import Combine import Foundation -import Observation import os import TableProPluginKit @@ -60,11 +60,11 @@ struct ExportState { // MARK: - Export Service -@MainActor @Observable -final class ExportService { +@MainActor +final class ExportService: ObservableObject { nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "ExportService") - var state = ExportState() + @Published var state = ExportState() private let driver: DatabaseDriver? private let databaseType: DatabaseType @@ -109,7 +109,7 @@ final class ExportService { currentProgress?.cancel() } - private var currentProgress: PluginExportProgress? + @Published private var currentProgress: PluginExportProgress? /// The status line a plugin writes with `PluginExportProgress.setStatus`. Nothing observed it, /// so "Compressing..." never reached a user in any export. The empty guard sits outside the hop diff --git a/TablePro/Core/Services/Export/ImportService.swift b/TablePro/Core/Services/Export/ImportService.swift index 726180ce06..9aadeae9c4 100644 --- a/TablePro/Core/Services/Export/ImportService.swift +++ b/TablePro/Core/Services/Export/ImportService.swift @@ -6,8 +6,8 @@ // creates the adapter/source objects, and wires progress to the UI. // +import Combine import Foundation -import Observation import os import TableProPluginKit @@ -25,15 +25,15 @@ struct ImportState { // MARK: - Import Service -@MainActor @Observable -final class ImportService { +@MainActor +final class ImportService: ObservableObject { nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "ImportService") - var state = ImportState() + @Published var state = ImportState() private let connection: DatabaseConnection private let historyRecorder: QueryHistoryRecording - private var currentProgress: PluginImportProgress? + @Published private var currentProgress: PluginImportProgress? init(connection: DatabaseConnection, historyRecorder: QueryHistoryRecording = QueryHistoryManager.shared) { self.connection = connection diff --git a/TablePro/Core/Services/Export/LinkedFolderWatcher.swift b/TablePro/Core/Services/Export/LinkedFolderWatcher.swift index 671eb6be99..3a424982d1 100644 --- a/TablePro/Core/Services/Export/LinkedFolderWatcher.swift +++ b/TablePro/Core/Services/Export/LinkedFolderWatcher.swift @@ -20,15 +20,14 @@ struct LinkedConnection: Identifiable, Sendable { } @MainActor -@Observable -final class LinkedFolderWatcher { +final class LinkedFolderWatcher: ObservableObject { static let shared = LinkedFolderWatcher() nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "LinkedFolderWatcher") - private(set) var linkedConnections: [LinkedConnection] = [] - private var watchSources: [UUID: DispatchSourceFileSystemObject] = [:] + @Published private(set) var linkedConnections: [LinkedConnection] = [] + @Published private var watchSources: [UUID: DispatchSourceFileSystemObject] = [:] private var debounceTask: Task? - private var hasStarted = false + @Published private var hasStarted = false private init() {} diff --git a/TablePro/Core/Services/Export/TableTransferService.swift b/TablePro/Core/Services/Export/TableTransferService.swift index 4c47d7f37f..993211a099 100644 --- a/TablePro/Core/Services/Export/TableTransferService.swift +++ b/TablePro/Core/Services/Export/TableTransferService.swift @@ -3,8 +3,8 @@ // TablePro // +import Combine import Foundation -import Observation import os import TableProPluginKit @@ -57,8 +57,7 @@ struct TableTransferState { /// crosses engines is a different problem from copying rows and getting it half right would create /// tables whose types quietly do not match. @MainActor -@Observable -final class TableTransferService { +final class TableTransferService: ObservableObject { nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "TableTransfer") /// What a transfer holds before it hands rows to the sink, bounded in bytes as well as rows: a @@ -72,7 +71,7 @@ final class TableTransferService { /// into one per row. static let batchBudget = SQLWriteBatchBudget(maxRows: 500, maxBytes: 64 * 1_048_576) - var state = TableTransferState() + @Published var state = TableTransferState() /// The same stop, in a form a `@Sendable` closure may read. `isCancelled` is main-actor /// isolated, and the sink splits one hand-off into many statements off that actor, so it needs diff --git a/TablePro/Core/Services/Infrastructure/AppActivationPolicyController.swift b/TablePro/Core/Services/Infrastructure/AppActivationPolicyController.swift index 69e7f78368..7f356f2481 100644 --- a/TablePro/Core/Services/Infrastructure/AppActivationPolicyController.swift +++ b/TablePro/Core/Services/Infrastructure/AppActivationPolicyController.swift @@ -65,8 +65,10 @@ internal final class AppActivationPolicyController { let promoted = enterForeground() if ignoringOtherApps || promoted { NSApp.activate(ignoringOtherApps: true) - } else { + } else if #available(macOS 14.0, *) { NSApp.activate() + } else { + NSApp.activate(ignoringOtherApps: false) } } diff --git a/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift b/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift index 9223ca4be8..8aeb38cd71 100644 --- a/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift +++ b/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift @@ -4,25 +4,24 @@ // import AppKit +import Combine import Foundation -import Observation import os @MainActor -@Observable -internal final class AppLaunchCoordinator { +internal final class AppLaunchCoordinator: ObservableObject { internal static let shared = AppLaunchCoordinator() nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "AppLaunchCoordinator") - private(set) var phase: LaunchPhase = .launching + @Published private(set) var phase: LaunchPhase = .launching - @ObservationIgnored private let environment: any LaunchEnvironment - private var pendingIntents: [LaunchIntent] = [] - private var hasFinishedLaunching = false - private var isDraining = false - private var hasRoutedAnyIntent = false - private var hasFinishedStartup = false + private let environment: any LaunchEnvironment + @Published private var pendingIntents: [LaunchIntent] = [] + @Published private var hasFinishedLaunching = false + @Published private var isDraining = false + @Published private var hasRoutedAnyIntent = false + @Published private var hasFinishedStartup = false internal init(environment: any LaunchEnvironment = LiveLaunchEnvironment()) { self.environment = environment diff --git a/TablePro/Core/Services/Infrastructure/ConnectionStageObserver.swift b/TablePro/Core/Services/Infrastructure/ConnectionStageObserver.swift index 2a0fbd1f47..5f04224f7f 100644 --- a/TablePro/Core/Services/Infrastructure/ConnectionStageObserver.swift +++ b/TablePro/Core/Services/Infrastructure/ConnectionStageObserver.swift @@ -11,13 +11,12 @@ import TableProPluginKit /// panes only on a phase change, so holding the stage here keeps a stage tick from tearing /// down and rebuilding the whole SwiftUI subtree. @MainActor -@Observable -internal final class ConnectionStageObserver { - internal private(set) var stage: ConnectionStage? - internal private(set) var isTakingLonger = false +internal final class ConnectionStageObserver: ObservableObject { + @Published internal private(set) var stage: ConnectionStage? + @Published internal private(set) var isTakingLonger = false - @ObservationIgnored private var cancellable: AnyCancellable? - @ObservationIgnored private var patienceTask: Task? + private var cancellable: AnyCancellable? + private var patienceTask: Task? private static let patience: Duration = .seconds(12) diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+TabStripAccessory.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+TabStripAccessory.swift index a0441ec363..b512456c1e 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+TabStripAccessory.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+TabStripAccessory.swift @@ -4,6 +4,7 @@ // import AppKit +import Combine import SwiftUI internal extension MainSplitViewController { @@ -64,16 +65,18 @@ internal extension MainSplitViewController { tabStripObservedManager = identity let generation = tabStripObservationGeneration - withObservationTracking { [weak self] in - _ = self?.workspaces.selected?.sessionState?.tabManager.tabs.count - } onChange: { [weak self] in - /// Observation reports the change before it lands, so the count is read on the next - /// turn of the main actor rather than in the callback. - Task { @MainActor [weak self] in - guard let self, generation == self.tabStripObservationGeneration else { return } - self.tabStripObservationIsArmed = false - self.applyTabStripVisibility() - } + /// `onMainActorChange` already delivers a turn late, which is what the count needs: + /// the publisher fires before the change lands. It also wakes for any tab mutation + /// rather than only a count change, so the count is compared here. + guard let manager = workspaces.selected?.sessionState?.tabManager else { return } + var lastCount = manager.tabs.count + tabStripObservation = manager.onMainActorChange { [weak self] in + guard let self, generation == self.tabStripObservationGeneration else { return } + let current = manager.tabs.count + guard current != lastCount else { return } + lastCount = current + self.tabStripObservationIsArmed = false + self.applyTabStripVisibility() } } @@ -114,7 +117,9 @@ internal extension MainSplitViewController { activate: { [weak manager] id in manager?.selectedTabId = id }, keepOpen: { [weak manager] id in manager?.promotePreviewTab(id: id) - FeatureTipSignals.tableKeptOpen() + if #available(macOS 14.0, *) { + FeatureTipSignals.tableKeptOpen() + } }, canKeepOpen: { [weak manager] id in manager?.canPromotePreviewTab(id: id) ?? false }, close: { [weak workspace] id in diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index f9bd1439fa..59b29767b5 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -110,6 +110,7 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan var tabStripObservationIsArmed = false var tabStripObservedManager: ObjectIdentifier? + var tabStripObservation: AnyCancellable? // MARK: - Panel Layout State diff --git a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Delegate.swift b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Delegate.swift index 57dca3fb6f..ed62c89293 100644 --- a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Delegate.swift +++ b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Delegate.swift @@ -25,6 +25,19 @@ extension MainWindowToolbar { willBeInsertedIntoToolbar flag: Bool ) -> NSToolbarItem? { switch itemIdentifier { + case Self.inspector: + /// AppKit builds `.toggleInspector` itself and never asks the delegate for it, so this + /// arm is dead on macOS 14. Below that the identifier is app-owned, and an identifier + /// the delegate does not answer for simply never appears in the toolbar. + guard #unavailable(macOS 14.0) else { return nil } + return menuOnlyItem( + id: itemIdentifier, + label: String(localized: "Inspector"), + symbol: "sidebar.trailing", + action: #selector(MainSplitViewController.toggleInspector(_:)), + shortcut: .toggleInspector, + description: String(localized: "Toggle Inspector") + ) case Self.sidebarToggle: return makeSidebarToggleItem(claimsSlot: Self.claimsItemSlot(willBeInsertedIntoToolbar: flag)) case Self.backForwardGroup: diff --git a/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift b/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift index c694c7b3af..edc845966d 100644 --- a/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift +++ b/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift @@ -5,11 +5,11 @@ import AppKit import Combine -import Observation import os @MainActor internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { + private var itemStateObservation: AnyCancellable? nonisolated internal static let lifecycleLogger = Logger(subsystem: "com.TablePro", category: "NativeTabLifecycle") /// The autosave name. Bumping it discards every saved arrangement, so it moves only when the @@ -228,21 +228,16 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { private func observeItemState() { let generation = itemStateObservationGeneration let coordinatorIdentifier = coordinator.map { ObjectIdentifier($0) } - withObservationTracking { [weak self] in - _ = self?.coordinator?.toolbarState.hasPendingChanges - _ = self?.coordinator?.toolbarState.hasDataPendingChanges - _ = self?.coordinator?.toolbarState.safeModeLevel - _ = self?.coordinator?.toolbarState.currentDatabase - _ = self?.coordinator?.toolbarState.isQueryTab - } onChange: { [weak self] in - Task { @MainActor [weak self] in - guard let self, - generation == self.itemStateObservationGeneration, - coordinatorIdentifier == self.coordinator.map({ ObjectIdentifier($0) }) - else { return } - self.observeItemState() - self.managedToolbar.validateVisibleItems() - } + /// Wakes for any change on the toolbar state rather than only the four properties the + /// tracked closure read. `validateVisibleItems()` is idempotent, so the wider wake set + /// costs a revalidation pass and nothing else. + guard let toolbarState = coordinator?.toolbarState else { return } + itemStateObservation = toolbarState.onMainActorChange { [weak self] in + guard let self, + generation == self.itemStateObservationGeneration, + coordinatorIdentifier == self.coordinator.map({ ObjectIdentifier($0) }) + else { return } + self.managedToolbar.validateVisibleItems() } } @@ -304,7 +299,14 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { static let newTab = NSToolbarItem.Identifier("com.TablePro.toolbar.newTab") static let previewSQL = NSToolbarItem.Identifier("com.TablePro.toolbar.previewSQL") static let results = NSToolbarItem.Identifier("com.TablePro.toolbar.results") - static let inspector = NSToolbarItem.Identifier.toggleInspector + /// `.toggleInspector` is macOS 14. The identifier only has to be stable and unique, and + /// AppKit's own inspector behaviour is not used here, so 13 gets an app-owned one. + static let inspector: NSToolbarItem.Identifier = { + if #available(macOS 14.0, *) { + return .toggleInspector + } + return NSToolbarItem.Identifier("com.TablePro.toolbar.inspector") + }() static let assistant = NSToolbarItem.Identifier("com.TablePro.toolbar.assistant") static let dashboard = NSToolbarItem.Identifier("com.TablePro.toolbar.dashboard") static let history = NSToolbarItem.Identifier("com.TablePro.toolbar.history") @@ -346,22 +348,27 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { /// compress a group it draws itself and can only drop a view it does not. /// /// Nothing here repeats the window title, which names the tab rather than the connection. - internal static let defaultItemIdentifiers: [NSToolbarItem.Identifier] = [ - sidebarToggle, - .sidebarTrackingSeparator, - backForwardGroup, - .flexibleSpace, - connectionGroup, - TransportRateToolbarItem.identifier, - .flexibleSpace, - refreshSaveGroup, - editorGroup, - safeMode, - .inspectorTrackingSeparator, - .flexibleSpace, - assistant, - inspector, - ] + /// `.inspectorTrackingSeparator` is macOS 14. Without it the divider does not track the + /// inspector's edge; the items around it are unchanged. + internal static var defaultItemIdentifiers: [NSToolbarItem.Identifier] { + var items: [NSToolbarItem.Identifier] = [ + sidebarToggle, + .sidebarTrackingSeparator, + backForwardGroup, + .flexibleSpace, + connectionGroup, + TransportRateToolbarItem.identifier, + .flexibleSpace, + refreshSaveGroup, + editorGroup, + safeMode, + ] + if #available(macOS 14.0, *) { + items.append(.inspectorTrackingSeparator) + } + items.append(contentsOf: [.flexibleSpace, assistant, inspector]) + return items + } /// `addRow`, `restorePreviousValues`, `quickSwitcher` and `newTab` are absent on purpose: they /// ride a group as subitems and the delegate vends no standalone item for any of them, so diff --git a/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift b/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift index 7f872c25a4..e4d8c49e4f 100644 --- a/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift +++ b/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift @@ -4,6 +4,7 @@ // import AppKit +import Combine import SwiftUI @MainActor @@ -179,13 +180,11 @@ internal final class SidebarContainerViewController: NSViewController { await withTaskCancellationHandler { await withCheckedContinuation { continuation in box.attach(continuation) - withObservationTracking { - _ = state.selectedSidebarTab - _ = state.searchText - _ = state.favoritesSearchText - } onChange: { + /// One-shot: the continuation resumes on the first change, and the sink is + /// released with the box, so nothing needs re-arming. + box.hold(state.onMainActorChange { box.resume() - } + }) } } onCancel: { box.resume() @@ -255,6 +254,16 @@ private final class ObservationContinuationBox: @unchecked Sendable { private let lock = NSLock() private var continuation: CheckedContinuation? private var resumed = false + private var observation: AnyCancellable? + + /// Keeps the subscription alive until the continuation resumes. `withObservationTracking` + /// needed nothing here because it fired once and released itself. + func hold(_ cancellable: AnyCancellable) { + lock.lock() + defer { lock.unlock() } + guard !resumed else { return } + observation = cancellable + } func attach(_ continuation: CheckedContinuation) { lock.lock() @@ -270,6 +279,7 @@ private final class ObservationContinuationBox: @unchecked Sendable { lock.lock() defer { lock.unlock() } guard !resumed else { return } + observation = nil resumed = true continuation?.resume() continuation = nil diff --git a/TablePro/Core/Services/Infrastructure/SoftwareUpdater.swift b/TablePro/Core/Services/Infrastructure/SoftwareUpdater.swift index e4ff4b3161..aa7b380fa7 100644 --- a/TablePro/Core/Services/Infrastructure/SoftwareUpdater.swift +++ b/TablePro/Core/Services/Infrastructure/SoftwareUpdater.swift @@ -3,8 +3,8 @@ // TablePro // +import Combine import Foundation -import Observation import os import Sparkle @@ -18,30 +18,29 @@ import Sparkle /// The published values are a mirror, not a store. Each is fed by KVO from the property that owns /// it, so a managed preference or Sparkle's own alert reaches the UI without anything having to /// notice it happened. -@Observable @MainActor -final class SoftwareUpdater { +final class SoftwareUpdater: ObservableObject { static let shared = SoftwareUpdater() nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "SoftwareUpdater") - @ObservationIgnored private let controller: SPUStandardUpdaterController + private let controller: SPUStandardUpdaterController /// Retained here because `SPUStandardUpdaterController` holds both delegates weakly. - @ObservationIgnored private let delegate = SoftwareUpdaterDelegate() - @ObservationIgnored private var observations: [NSKeyValueObservation] = [] - @ObservationIgnored private var hasStarted = false + private let delegate = SoftwareUpdaterDelegate() + private var observations: [NSKeyValueObservation] = [] + private var hasStarted = false - private(set) var canCheckForUpdates = false - private(set) var automaticallyChecksForUpdates = true - private(set) var automaticallyDownloadsUpdates = true - private(set) var allowsAutomaticUpdates = true - private(set) var updateCheckInterval: TimeInterval = 0 - private(set) var lastUpdateCheckDate: Date? + @Published private(set) var canCheckForUpdates = false + @Published private(set) var automaticallyChecksForUpdates = true + @Published private(set) var automaticallyDownloadsUpdates = true + @Published private(set) var allowsAutomaticUpdates = true + @Published private(set) var updateCheckInterval: TimeInterval = 0 + @Published private(set) var lastUpdateCheckDate: Date? /// A scheduled update the app declined to put in front of the user. Both the app menu item and /// the settings button read it, because a gentle reminder that shows in one place a person /// never opens is the same as no reminder at all. - private(set) var hasPendingUpdate = false + @Published private(set) var hasPendingUpdate = false private init() { controller = SPUStandardUpdaterController( diff --git a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift index dbba41926c..13066b977a 100644 --- a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift +++ b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift @@ -3,8 +3,8 @@ // TablePro // +import Combine import Foundation -import Observation import os internal struct RestoreResult { @@ -20,20 +20,20 @@ internal struct RestoreResult { } } -@MainActor @Observable -internal final class TabPersistenceCoordinator { +@MainActor +internal final class TabPersistenceCoordinator: ObservableObject { nonisolated internal static let logger = Logger(subsystem: "com.TablePro", category: "NativeTabLifecycle") let connectionId: UUID - @ObservationIgnored private var saveTask: Task? + private var saveTask: Task? /// Whether this window has ever had a tab of its own. Only a window that held tabs can report /// that the user closed them all; one that never saw any is not evidence of anything. A window /// left over from a disconnect is exactly that case, and treating its empty tab list as an /// instruction deleted the state the disconnect had just saved. - private(set) var hasObservedTabs = false + @Published private(set) var hasObservedTabs = false - @ObservationIgnored nonisolated(unsafe) private static var shared: [UUID: TabPersistenceCoordinator] = [:] + nonisolated(unsafe) private static var shared: [UUID: TabPersistenceCoordinator] = [:] /// One per connection, however many windows host it. /// diff --git a/TablePro/Core/Services/Infrastructure/WelcomeRouter.swift b/TablePro/Core/Services/Infrastructure/WelcomeRouter.swift index 9d1338c123..224375cd2d 100644 --- a/TablePro/Core/Services/Infrastructure/WelcomeRouter.swift +++ b/TablePro/Core/Services/Infrastructure/WelcomeRouter.swift @@ -6,7 +6,6 @@ import AppKit import Combine import Foundation -import Observation import TableProImport internal struct PendingConnectionError { @@ -37,18 +36,17 @@ internal enum WelcomeRequest { } @MainActor -@Observable -internal final class WelcomeRouter { +internal final class WelcomeRouter: ObservableObject { internal static let shared = WelcomeRouter() - private(set) var pendingRequest: WelcomeRequest? - private(set) var pendingImport: ExportableConnection? - private(set) var pendingConnectionShare: URL? - private(set) var pendingSQLFiles: [URL] = [] - private(set) var pendingError: PendingConnectionError? - private(set) var pendingPluginInstall: DatabaseConnection? + @Published private(set) var pendingRequest: WelcomeRequest? + @Published private(set) var pendingImport: ExportableConnection? + @Published private(set) var pendingConnectionShare: URL? + @Published private(set) var pendingSQLFiles: [URL] = [] + @Published private(set) var pendingError: PendingConnectionError? + @Published private(set) var pendingPluginInstall: DatabaseConnection? - @ObservationIgnored private var databaseDidConnectCancellable: AnyCancellable? + private var databaseDidConnectCancellable: AnyCancellable? internal init(appEvents: AppEvents = .shared) { databaseDidConnectCancellable = appEvents.databaseDidConnect diff --git a/TablePro/Core/Services/Infrastructure/WindowOpener.swift b/TablePro/Core/Services/Infrastructure/WindowOpener.swift index 547367c2cc..7a028bc75c 100644 --- a/TablePro/Core/Services/Infrastructure/WindowOpener.swift +++ b/TablePro/Core/Services/Infrastructure/WindowOpener.swift @@ -4,23 +4,22 @@ // import AppKit -import Observation +import Combine import os @MainActor -@Observable -internal final class WindowOpener { +internal final class WindowOpener: ObservableObject { internal static let shared = WindowOpener() nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "WindowOpener") - @ObservationIgnored private var openWelcomeAction: (() -> Void)? - @ObservationIgnored private var openConnectionFormAction: ((ConnectionFormRequest) -> Void)? - @ObservationIgnored private var openIntegrationsActivityAction: (() -> Void)? - @ObservationIgnored private var openCompareSyncAction: ((UUID?) -> Void)? - @ObservationIgnored private var openSettingsAction: ((SettingsPane?) -> Void)? - @ObservationIgnored private var stagedDraftId: UUID? - @ObservationIgnored private var pendingCalls: [() -> Void] = [] + private var openWelcomeAction: (() -> Void)? + private var openConnectionFormAction: ((ConnectionFormRequest) -> Void)? + private var openIntegrationsActivityAction: (() -> Void)? + private var openCompareSyncAction: ((UUID?) -> Void)? + private var openSettingsAction: ((SettingsPane?) -> Void)? + private var stagedDraftId: UUID? + private var pendingCalls: [() -> Void] = [] /// Not private so a test can exercise the queue on an instance with no presenters /// registered. Production code uses `shared`. diff --git a/TablePro/Core/Services/Infrastructure/WorkspaceRailViewController.swift b/TablePro/Core/Services/Infrastructure/WorkspaceRailViewController.swift index ebb545e5ce..6ca87d23a1 100644 --- a/TablePro/Core/Services/Infrastructure/WorkspaceRailViewController.swift +++ b/TablePro/Core/Services/Infrastructure/WorkspaceRailViewController.swift @@ -43,6 +43,7 @@ internal final class WorkspaceRailTableView: NSTableView { @MainActor internal final class WorkspaceRailViewController: NSViewController { + private var rowSizeObservation: AnyCancellable? nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "WorkspaceRail") private static let reorderType = NSPasteboard.PasteboardType("com.TablePro.workspaceRailEntry") @@ -323,14 +324,14 @@ internal final class WorkspaceRailViewController: NSViewController { /// Re-arms itself, because `withObservationTracking` fires once per registration. private func observeRowSizePreference() { - withObservationTracking { - _ = AppSettingsManager.shared.general.sidebarRowSize - } onChange: { [weak self] in - Task { @MainActor in - guard let self else { return } - self.refreshLayoutIfNeeded() - self.observeRowSizePreference() - } + /// `objectWillChange` covers every settings group, so the row size is compared here + /// to keep the rebuild as rare as the per-property tracking made it. + var lastRowSize = AppSettingsManager.shared.general.sidebarRowSize + rowSizeObservation = AppSettingsManager.shared.onMainActorChange { [weak self] in + let current = AppSettingsManager.shared.general.sidebarRowSize + guard current != lastRowSize else { return } + lastRowSize = current + self?.refreshLayoutIfNeeded() } } diff --git a/TablePro/Core/Services/Licensing/LicenseManager.swift b/TablePro/Core/Services/Licensing/LicenseManager.swift index dc4fda6620..5be753ddf5 100644 --- a/TablePro/Core/Services/Licensing/LicenseManager.swift +++ b/TablePro/Core/Services/Licensing/LicenseManager.swift @@ -7,7 +7,6 @@ import Combine import Foundation -import Observation import os /// Why a cached license blob was not adopted at launch. @@ -32,14 +31,14 @@ internal enum CachedLicenseResolution: Equatable { } /// Manages the app's license state with offline-first verification -@MainActor @Observable -final class LicenseManager { +@MainActor +final class LicenseManager: ObservableObject { static let shared = LicenseManager() nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "LicenseManager") /// Current cached license (nil = unlicensed) - private(set) var license: License? + @Published private(set) var license: License? /// Current license status. /// @@ -48,7 +47,7 @@ final class LicenseManager { /// reporting a healthy sync for a license that no longer existed until the next launch. /// The observer subscribes with `.receive(on: RunLoop.main)`, so this cannot re-enter a /// mutation that is still in progress. - private(set) var status: LicenseStatus = .unlicensed { + @Published private(set) var status: LicenseStatus = .unlicensed { didSet { guard status != oldValue else { return } AppEvents.shared.licenseStatusDidChange.send(()) @@ -59,7 +58,7 @@ final class LicenseManager { private(set) var isValidating: Bool = false /// Last error from an operation (cleared on success) - private(set) var lastError: LicenseError? + @Published private(set) var lastError: LicenseError? private let storage = LicenseStorage.shared private let apiClient = LicenseAPIClient.shared @@ -74,54 +73,54 @@ final class LicenseManager { /// What the server last told us about this license, when that was a rejection rather than a /// new payload. Deliberately not persisted: it can only take entitlement away, and writing it /// to disk would put licensing state back outside the signature. - private var serverRejection: LicenseStatus? + @Published private var serverRejection: LicenseStatus? /// When the server last confirmed this license, measured by this Mac's clock. Held in memory /// only, so nothing on disk can forge it and a relaunch falls back to the signed issue date. /// It exists so a server clock far behind the Mac cannot expire the grace period on a license /// the server has just approved. - private var lastServerContact: Date? + @Published private var lastServerContact: Date? /// Whether this Mac's license was removed here rather than never having existed. It is what /// separates `.deactivated` from `.unlicensed`, and it is deliberately not persisted: a relaunch /// with no license is simply unlicensed. - private var wasDeactivatedLocally = false + @Published private var wasDeactivatedLocally = false /// The seats this license is activated on. Owned here rather than by the settings view so the /// list survives the pane being reselected, and so an activation elsewhere can reset it. /// See `LicenseManager+Devices`. - internal var devices: [LicenseActivationInfo] = [] + @Published internal var devices: [LicenseActivationInfo] = [] - internal var maxDevices: Int = 0 + @Published internal var maxDevices: Int = 0 - internal var deviceListState: LicenseDeviceListState = .idle + @Published internal var deviceListState: LicenseDeviceListState = .idle /// A reload of a list that already has content. Separate from `deviceListState` so a refresh /// never blanks the seats it is refreshing, per the CLAUDE.md invariant. - internal var isRefreshingDevices = false + @Published internal var isRefreshingDevices = false /// Seats with a release in flight, so a row cannot be released twice. - internal var releasingMachineIds: Set = [] + @Published internal var releasingMachineIds: Set = [] /// Why the last release did not go through. Kept apart from `deviceListState` so a failure on /// one seat is reported beside the list rather than replacing every other seat with an error. - internal var releaseErrorMessage: String? + @Published internal var releaseErrorMessage: String? /// Why the last refresh of an already-loaded list did not go through. Separate from /// `releaseErrorMessage` because its wording is about reloading, not about giving up a seat. - internal var refreshErrorMessage: String? + @Published internal var refreshErrorMessage: String? /// The team roster, for a Team license. See `LicenseManager+Team`. - internal var team: LicenseTeamResponse? + @Published internal var team: LicenseTeamResponse? - internal var teamListState: LicenseDeviceListState = .idle + @Published internal var teamListState: LicenseDeviceListState = .idle nonisolated internal static let deviceLogger = Logger( subsystem: "com.TablePro", category: "LicenseDevices" ) - @ObservationIgnored private var revalidationTask: Task? + private var revalidationTask: Task? private init() { loadCachedLicense() diff --git a/TablePro/Core/Services/Notifications/PluginNotificationService.swift b/TablePro/Core/Services/Notifications/PluginNotificationService.swift index 7ec6a345f7..b8884e2277 100644 --- a/TablePro/Core/Services/Notifications/PluginNotificationService.swift +++ b/TablePro/Core/Services/Notifications/PluginNotificationService.swift @@ -8,8 +8,8 @@ import Foundation import os import UserNotifications -@MainActor @Observable -final class PluginNotificationService { +@MainActor +final class PluginNotificationService: ObservableObject { static let shared = PluginNotificationService() static let identifierPrefix = "com.TablePro.plugin." @@ -18,10 +18,10 @@ final class PluginNotificationService { private static let failedIdentifierPrefix = identifierPrefix + "failed." nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "PluginNotifications") - private(set) var authorizationStatus: UNAuthorizationStatus = .notDetermined + @Published private(set) var authorizationStatus: UNAuthorizationStatus = .notDetermined - @ObservationIgnored private var cancellables: Set = [] - @ObservationIgnored private var deliveredFailureIdentifiers: Set = [] + private var cancellables: Set = [] + private var deliveredFailureIdentifiers: Set = [] private init() {} diff --git a/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift index 658c0b16e0..6167f8a247 100644 --- a/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift +++ b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift @@ -3,13 +3,13 @@ // TablePro // +import Combine import Foundation import os import TableProPluginKit @MainActor -@Observable -final class DatabaseTreeMetadataService: CatalogChangeTarget { +final class DatabaseTreeMetadataService: ObservableObject, CatalogChangeTarget { static let shared = DatabaseTreeMetadataService() struct DatabaseKey: Hashable, Sendable { @@ -30,31 +30,31 @@ final class DatabaseTreeMetadataService: CatalogChangeTarget { let table: String } - private(set) var databaseList: [UUID: MetadataLoadState<[DatabaseMetadata]>] = [:] - private(set) var schemaList: [DatabaseKey: MetadataLoadState<[String]>] = [:] - private(set) var tablesState: [ObjectsKey: MetadataLoadState<[TableInfo]>] = [:] - private(set) var routinesState: [ObjectsKey: MetadataLoadState<[RoutineInfo]>] = [:] - private(set) var triggersState: [ObjectsKey: MetadataLoadState<[TriggerInfo]>] = [:] - private(set) var typesState: [ObjectsKey: MetadataLoadState<[UserDefinedTypeInfo]>] = [:] - private(set) var partitionsState: [PartitionsKey: MetadataLoadState<[TableInfo]>] = [:] - - @ObservationIgnored private let databaseDedup = OnceTask() - @ObservationIgnored private let schemaDedup = OnceTask() - @ObservationIgnored private let tablesDedup = OnceTask() - @ObservationIgnored private let routinesDedup = OnceTask() - @ObservationIgnored private let triggersDedup = OnceTask() - @ObservationIgnored private let typesDedup = OnceTask() - @ObservationIgnored private let partitionsDedup = OnceTask() - - @ObservationIgnored private var databaseListFence = CommitFence() - @ObservationIgnored private var schemaListFence = CommitFence() - @ObservationIgnored private var tablesFence = CommitFence() - @ObservationIgnored private var routinesFence = CommitFence() - @ObservationIgnored private var triggersFence = CommitFence() - @ObservationIgnored private var typesFence = CommitFence() - @ObservationIgnored private var partitionsFence = CommitFence() - - @ObservationIgnored nonisolated private static let logger = Logger( + @Published private(set) var databaseList: [UUID: MetadataLoadState<[DatabaseMetadata]>] = [:] + @Published private(set) var schemaList: [DatabaseKey: MetadataLoadState<[String]>] = [:] + @Published private(set) var tablesState: [ObjectsKey: MetadataLoadState<[TableInfo]>] = [:] + @Published private(set) var routinesState: [ObjectsKey: MetadataLoadState<[RoutineInfo]>] = [:] + @Published private(set) var triggersState: [ObjectsKey: MetadataLoadState<[TriggerInfo]>] = [:] + @Published private(set) var typesState: [ObjectsKey: MetadataLoadState<[UserDefinedTypeInfo]>] = [:] + @Published private(set) var partitionsState: [PartitionsKey: MetadataLoadState<[TableInfo]>] = [:] + + private let databaseDedup = OnceTask() + private let schemaDedup = OnceTask() + private let tablesDedup = OnceTask() + private let routinesDedup = OnceTask() + private let triggersDedup = OnceTask() + private let typesDedup = OnceTask() + private let partitionsDedup = OnceTask() + + private var databaseListFence = CommitFence() + private var schemaListFence = CommitFence() + private var tablesFence = CommitFence() + private var routinesFence = CommitFence() + private var triggersFence = CommitFence() + private var typesFence = CommitFence() + private var partitionsFence = CommitFence() + + nonisolated private static let logger = Logger( subsystem: "com.TablePro", category: "SidebarTree" ) diff --git a/TablePro/Core/Services/Query/ExternalSchemaTracker.swift b/TablePro/Core/Services/Query/ExternalSchemaTracker.swift index 624bb18e18..1dec970c84 100644 --- a/TablePro/Core/Services/Query/ExternalSchemaTracker.swift +++ b/TablePro/Core/Services/Query/ExternalSchemaTracker.swift @@ -3,12 +3,12 @@ // TablePro // +import Combine import Foundation import os @MainActor -@Observable -final class ExternalSchemaTracker { +final class ExternalSchemaTracker: ObservableObject { static let shared = ExternalSchemaTracker() struct Key: Hashable, Sendable { @@ -18,9 +18,9 @@ final class ExternalSchemaTracker { nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "ExternalSchemaTracker") - private var namesByDatabase: [Key: Set] = [:] + @Published private var namesByDatabase: [Key: Set] = [:] - @ObservationIgnored private let dedup = OnceTask>() + private let dedup = OnceTask>() private init() {} diff --git a/TablePro/Core/Services/Query/SchemaService.swift b/TablePro/Core/Services/Query/SchemaService.swift index b441079704..17def344bb 100644 --- a/TablePro/Core/Services/Query/SchemaService.swift +++ b/TablePro/Core/Services/Query/SchemaService.swift @@ -3,13 +3,13 @@ // TablePro // +import Combine import Foundation import os import TableProPluginKit @MainActor -@Observable -final class SchemaService { +final class SchemaService: ObservableObject { static let shared = SchemaService() /// The object kinds that are not tables, each behind its own load state so a list that is still @@ -20,14 +20,14 @@ final class SchemaService { var userDefinedTypes: MetadataLoadState<[UserDefinedTypeInfo]> = .idle } - private(set) var states: [UUID: SchemaState] = [:] - private(set) var sideObjects: [UUID: SideObjects] = [:] - private(set) var schemasInOrder: [UUID: [String]] = [:] - private(set) var perSchemaStates: [UUID: [String: SchemaState]] = [:] - private(set) var perSchemaSideObjects: [UUID: [String: SideObjects]] = [:] - private(set) var generations: [UUID: Int] = [:] - private(set) var refreshingConnections: Set = [] - private(set) var loadedScopes: [UUID: DatabaseScope] = [:] + @Published private(set) var states: [UUID: SchemaState] = [:] + @Published private(set) var sideObjects: [UUID: SideObjects] = [:] + @Published private(set) var schemasInOrder: [UUID: [String]] = [:] + @Published private(set) var perSchemaStates: [UUID: [String: SchemaState]] = [:] + @Published private(set) var perSchemaSideObjects: [UUID: [String: SideObjects]] = [:] + @Published private(set) var generations: [UUID: Int] = [:] + @Published private(set) var refreshingConnections: Set = [] + @Published private(set) var loadedScopes: [UUID: DatabaseScope] = [:] func generationToken(for connectionId: UUID) -> Int { generations[connectionId] ?? 0 @@ -37,15 +37,15 @@ final class SchemaService { generations[connectionId, default: 0] &+= 1 } - @ObservationIgnored private let loadDedup = OnceTask() - @ObservationIgnored private let routinesDedup = OnceTask() - @ObservationIgnored private let triggersDedup = OnceTask() - @ObservationIgnored private let typesDedup = OnceTask() - @ObservationIgnored private let schemasDedup = OnceTask() - @ObservationIgnored private let perSchemaDedup = OnceTask() - @ObservationIgnored private let perSchemaRoutinesDedup = OnceTask() - @ObservationIgnored private let perSchemaTriggersDedup = OnceTask() - @ObservationIgnored private let perSchemaTypesDedup = OnceTask() + private let loadDedup = OnceTask() + private let routinesDedup = OnceTask() + private let triggersDedup = OnceTask() + private let typesDedup = OnceTask() + private let schemasDedup = OnceTask() + private let perSchemaDedup = OnceTask() + private let perSchemaRoutinesDedup = OnceTask() + private let perSchemaTriggersDedup = OnceTask() + private let perSchemaTypesDedup = OnceTask() struct SchemaKey: Hashable, Sendable { let connectionId: UUID @@ -78,11 +78,11 @@ final class SchemaService { let continuation: CheckedContinuation } - @ObservationIgnored private var loadGenerations: [UUID: Int] = [:] - @ObservationIgnored private var schemaLoadGenerations: [SchemaKey: Int] = [:] - @ObservationIgnored private var refreshWaiters: [UUID: [RefreshWaiter]] = [:] - @ObservationIgnored private var nextLoadGeneration = 0 - @ObservationIgnored nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "SchemaService") + private var loadGenerations: [UUID: Int] = [:] + private var schemaLoadGenerations: [SchemaKey: Int] = [:] + private var refreshWaiters: [UUID: [RefreshWaiter]] = [:] + private var nextLoadGeneration = 0 + nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "SchemaService") func state(for connectionId: UUID) -> SchemaState { states[connectionId] ?? .idle diff --git a/TablePro/Core/Services/SQL/SQLFolderWatcher.swift b/TablePro/Core/Services/SQL/SQLFolderWatcher.swift index 8e75f227bb..98cab5b488 100644 --- a/TablePro/Core/Services/SQL/SQLFolderWatcher.swift +++ b/TablePro/Core/Services/SQL/SQLFolderWatcher.swift @@ -9,16 +9,15 @@ import Foundation import os @MainActor -@Observable -internal final class SQLFolderWatcher { +internal final class SQLFolderWatcher: ObservableObject { static let shared = SQLFolderWatcher() nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "SQLFolderWatcher") - private(set) var lastScanCompletedAt: Date? + @Published private(set) var lastScanCompletedAt: Date? - @ObservationIgnored private var eventStream: FSEventStreamRef? - @ObservationIgnored private var debounceTask: Task? - @ObservationIgnored private var hasStarted = false + private var eventStream: FSEventStreamRef? + private var debounceTask: Task? + private var hasStarted = false nonisolated private static let eventCallback: FSEventStreamCallback = { _, info, _, _, _, _ in guard let info else { return } diff --git a/TablePro/Core/Services/TeamLibrary/TeamLibrarySyncCoordinator.swift b/TablePro/Core/Services/TeamLibrary/TeamLibrarySyncCoordinator.swift index 97f80c2d00..09df5c52e0 100644 --- a/TablePro/Core/Services/TeamLibrary/TeamLibrarySyncCoordinator.swift +++ b/TablePro/Core/Services/TeamLibrary/TeamLibrarySyncCoordinator.swift @@ -13,8 +13,7 @@ import os import TableProImport @MainActor -@Observable -final class TeamLibrarySyncCoordinator { +final class TeamLibrarySyncCoordinator: ObservableObject { static let shared = TeamLibrarySyncCoordinator() nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "TeamLibrarySyncCoordinator") @@ -24,8 +23,8 @@ final class TeamLibrarySyncCoordinator { private let isFeatureAvailable: @MainActor () -> Bool private let credentialsProvider: @MainActor () -> (key: String, machineId: String)? - private(set) var library: TeamLibraryPullResponse = .empty - private(set) var isPublishing = false + @Published private(set) var library: TeamLibraryPullResponse = .empty + @Published private(set) var isPublishing = false init( apiClient: TeamLibraryAPIClient = LiveTeamLibraryAPIClient.shared, diff --git a/TablePro/Core/Storage/AppSettingsManager.swift b/TablePro/Core/Storage/AppSettingsManager.swift index 6103ff2ef0..d9b8b557a5 100644 --- a/TablePro/Core/Storage/AppSettingsManager.swift +++ b/TablePro/Core/Storage/AppSettingsManager.swift @@ -1,15 +1,14 @@ import AppKit import Combine import Foundation -import Observation import os +import TableProSyncTransport -@Observable @MainActor -final class AppSettingsManager { +final class AppSettingsManager: ObservableObject { static let shared = AppSettingsManager() - var general: GeneralSettings { + @Published var general: GeneralSettings { didSet { general.language.apply() storage.saveGeneral(general) @@ -23,7 +22,7 @@ final class AppSettingsManager { } } - var appearance: AppearanceSettings { + @Published var appearance: AppearanceSettings { didSet { storage.saveAppearance(appearance) themeEngine.updateAppearanceAndTheme( @@ -35,7 +34,7 @@ final class AppSettingsManager { } } - var editor: EditorSettings { + @Published var editor: EditorSettings { didSet { storage.saveEditor(editor) themeEngine.updateEditorSettings( @@ -51,7 +50,7 @@ final class AppSettingsManager { } } - var notifications: NotificationSettings { + @Published var notifications: NotificationSettings { didSet { guard !isValidating else { return } var validated = notifications @@ -66,7 +65,7 @@ final class AppSettingsManager { } } - var dataGrid: DataGridSettings { + @Published var dataGrid: DataGridSettings { didSet { guard !isValidating else { return } var validated = dataGrid @@ -86,7 +85,7 @@ final class AppSettingsManager { } } - var history: HistorySettings { + @Published var history: HistorySettings { didSet { guard !isValidating else { return } var validated = history @@ -105,14 +104,14 @@ final class AppSettingsManager { } } - var tabs: TabSettings { + @Published var tabs: TabSettings { didSet { storage.saveTabs(tabs) syncTracker.markDirty(.settings, id: AppSettingsCategory.tabs) } } - var keyboard: KeyboardSettings { + @Published var keyboard: KeyboardSettings { didSet { storage.saveKeyboard(keyboard) syncTracker.markDirty(.settings, id: AppSettingsCategory.keyboard) @@ -121,7 +120,7 @@ final class AppSettingsManager { } } - var ai: AISettings { + @Published var ai: AISettings { didSet { storage.saveAI(ai) syncTracker.markDirty(.settings, id: AppSettingsCategory.ai) @@ -140,13 +139,13 @@ final class AppSettingsManager { } } - var sync: SyncSettings { + @Published var sync: SyncSettings { didSet { storage.saveSync(sync) } } - var mcp: MCPSettings { + @Published var mcp: MCPSettings { didSet { guard !isValidating else { return } var validated = mcp @@ -202,15 +201,15 @@ final class AppSettingsManager { return result } - @ObservationIgnored private let storage: AppSettingsStorage - @ObservationIgnored private let themeEngine: ThemeEngine - @ObservationIgnored private let syncTracker: SyncChangeTracker - @ObservationIgnored private let appEvents: AppEvents - @ObservationIgnored private let dateFormattingService: DateFormattingService - @ObservationIgnored private let queryHistoryManager: QueryHistoryManager - @ObservationIgnored private let mcpServerManager: MCPServerManager - @ObservationIgnored private let copilotService: CopilotService - @ObservationIgnored private var isValidating = false + private let storage: AppSettingsStorage + private let themeEngine: ThemeEngine + private let syncTracker: SyncChangeTracker + private let appEvents: AppEvents + private let dateFormattingService: DateFormattingService + private let queryHistoryManager: QueryHistoryManager + private let mcpServerManager: MCPServerManager + private let copilotService: CopilotService + private var isValidating = false init( storage: AppSettingsStorage = .shared, diff --git a/TablePro/Core/Storage/CustomSlashCommandStorage.swift b/TablePro/Core/Storage/CustomSlashCommandStorage.swift index 101ecd5174..7c9c4b51ad 100644 --- a/TablePro/Core/Storage/CustomSlashCommandStorage.swift +++ b/TablePro/Core/Storage/CustomSlashCommandStorage.swift @@ -3,8 +3,8 @@ // TablePro // +import Combine import Foundation -import Observation import os import TableProSyncTransport @@ -23,8 +23,7 @@ enum CustomSlashCommandError: LocalizedError, Equatable { } @MainActor -@Observable -final class CustomSlashCommandStorage { +final class CustomSlashCommandStorage: ObservableObject { static let shared = CustomSlashCommandStorage() static let syncCategory = "customSlashCommands" @@ -34,7 +33,7 @@ final class CustomSlashCommandStorage { private let defaults: UserDefaults private let syncTracker: SyncChangeTracker - private(set) var commands: [CustomSlashCommand] = [] + @Published private(set) var commands: [CustomSlashCommand] = [] init(defaults: UserDefaults = .standard, syncTracker: SyncChangeTracker = .shared) { self.defaults = defaults diff --git a/TablePro/Core/Storage/HighlightRuleStorage.swift b/TablePro/Core/Storage/HighlightRuleStorage.swift index 363afb9d91..a67772dd3e 100644 --- a/TablePro/Core/Storage/HighlightRuleStorage.swift +++ b/TablePro/Core/Storage/HighlightRuleStorage.swift @@ -3,13 +3,12 @@ // TablePro // +import Combine import Foundation -import Observation import os @MainActor -@Observable -final class HighlightRuleStorage: TableScopedSettingsStore { +final class HighlightRuleStorage: ObservableObject, TableScopedSettingsStore { static let shared = HighlightRuleStorage() nonisolated private static let logger = Logger( @@ -17,16 +16,16 @@ final class HighlightRuleStorage: TableScopedSettingsStore { category: "HighlightRuleStorage" ) - private(set) var revision = 0 + @Published private(set) var revision = 0 - @ObservationIgnored private let storageDirectory: URL - @ObservationIgnored private var cache: [UUID: [String: [HighlightRule]]] = [:] - @ObservationIgnored private let encoder: JSONEncoder = { + private let storageDirectory: URL + private var cache: [UUID: [String: [HighlightRule]]] = [:] + private let encoder: JSONEncoder = { let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys] return encoder }() - @ObservationIgnored private let decoder = JSONDecoder() + private let decoder = JSONDecoder() init(storageDirectory: URL? = nil) { self.storageDirectory = storageDirectory ?? Self.resolvedStorageDirectory() diff --git a/TablePro/Core/Storage/QueryHistoryCaptureStore.swift b/TablePro/Core/Storage/QueryHistoryCaptureStore.swift index 21db02d9d4..316b544de3 100644 --- a/TablePro/Core/Storage/QueryHistoryCaptureStore.swift +++ b/TablePro/Core/Storage/QueryHistoryCaptureStore.swift @@ -1,5 +1,5 @@ +import Combine import Foundation -import Observation /// Pausing is a local, momentary decision: "do not record what I am about to run on this Mac". /// It deliberately does not live in `HistorySettings`, which syncs, because pausing on a laptop @@ -16,11 +16,10 @@ enum QueryHistoryCaptureStore { /// Every open drawer shows the same pause state, so it is observed from one place rather than /// mirrored per connection, where two windows would disagree until one of them reloaded. @MainActor -@Observable -final class QueryHistoryCaptureState { +final class QueryHistoryCaptureState: ObservableObject { static let shared = QueryHistoryCaptureState() - var isPaused: Bool { + @Published var isPaused: Bool { didSet { guard oldValue != isPaused else { return } QueryHistoryCaptureStore.isPaused = isPaused diff --git a/TablePro/Core/Storage/RecentlyClosedTabStore.swift b/TablePro/Core/Storage/RecentlyClosedTabStore.swift index 8d97f0f8a1..2477e719a7 100644 --- a/TablePro/Core/Storage/RecentlyClosedTabStore.swift +++ b/TablePro/Core/Storage/RecentlyClosedTabStore.swift @@ -1,5 +1,5 @@ +import Combine import Foundation -import Observation import os internal struct RecentlyClosedTabEntry: Codable, Identifiable { @@ -44,8 +44,7 @@ internal extension RecentlyClosedTabEntry { /// windows on every save, so a closed tab necessarily falls out of it. This store is the /// append-and-prune log that lets a closed tab come back. @MainActor -@Observable -internal final class RecentlyClosedTabStore { +internal final class RecentlyClosedTabStore: ObservableObject { internal static let shared = RecentlyClosedTabStore() internal static let maxEntries = 20 @@ -53,9 +52,9 @@ internal final class RecentlyClosedTabStore { nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "RecentlyClosedTabStore") - internal private(set) var entries: [RecentlyClosedTabEntry] = [] + @Published internal private(set) var entries: [RecentlyClosedTabEntry] = [] - @ObservationIgnored private let directory: URL + private let directory: URL internal init(directory: URL = RecentlyClosedTabStore.defaultDirectory()) { self.directory = directory diff --git a/TablePro/Core/Storage/SQLFavoriteEditValidation.swift b/TablePro/Core/Storage/SQLFavoriteEditValidation.swift index 4518f48e69..308706ac5f 100644 --- a/TablePro/Core/Storage/SQLFavoriteEditValidation.swift +++ b/TablePro/Core/Storage/SQLFavoriteEditValidation.swift @@ -3,6 +3,7 @@ // TablePro // +import Combine import Foundation import Observation @@ -63,10 +64,9 @@ internal enum SQLFavoriteKeywordValidator { } @MainActor -@Observable -internal final class SQLFavoriteKeywordField { - var keyword = "" - private(set) var validation: SQLFavoriteKeywordValidation = .valid +internal final class SQLFavoriteKeywordField: ObservableObject { + @Published var keyword = "" + @Published private(set) var validation: SQLFavoriteKeywordValidation = .valid private var validationId = 0 private let availabilityCheck: (String, UUID?, UUID?) async -> Bool diff --git a/TablePro/Core/Sync/SyncCoordinator.swift b/TablePro/Core/Sync/SyncCoordinator.swift index 32a8999f24..86b3a8cc8d 100644 --- a/TablePro/Core/Sync/SyncCoordinator.swift +++ b/TablePro/Core/Sync/SyncCoordinator.swift @@ -8,38 +8,37 @@ import CloudKit import Combine import Foundation -import Observation import os import TableProSyncTransport /// Central coordinator for iCloud sync -@MainActor @Observable -final class SyncCoordinator { +@MainActor +final class SyncCoordinator: ObservableObject { static let shared = SyncCoordinator() nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "SyncCoordinator") - private(set) var syncStatus: SyncStatus = .disabled(.userDisabled) - private(set) var lastSyncDate: Date? - private(set) var iCloudAccountAvailable: Bool = false + @Published private(set) var syncStatus: SyncStatus = .disabled(.userDisabled) + @Published private(set) var lastSyncDate: Date? + @Published private(set) var iCloudAccountAvailable: Bool = false - @ObservationIgnored private let services: AppServices - @ObservationIgnored private let engine = CloudKitSyncEngine() - @ObservationIgnored private let changeTracker: SyncChangeTracker - @ObservationIgnored private let metadataStorage: SyncMetadataStorage - @ObservationIgnored private let recordCache = SyncRecordCache( + private let services: AppServices + private let engine = CloudKitSyncEngine() + private let changeTracker: SyncChangeTracker + private let metadataStorage: SyncMetadataStorage + private let recordCache = SyncRecordCache( directory: AppStorageEnvironment.shared.supportDirectory .appendingPathComponent("SyncRecordCache", isDirectory: true), defaults: AppStorageEnvironment.shared.defaults ) - @ObservationIgnored private let accountObserver = OSAllocatedUnfairLock<(any NSObjectProtocol)?>(uncheckedState: nil) - @ObservationIgnored private var changeCancellable: AnyCancellable? - @ObservationIgnored private var licenseCancellable: AnyCancellable? - @ObservationIgnored private var syncTask: Task? - @ObservationIgnored private var hasStarted = false + private let accountObserver = OSAllocatedUnfairLock<(any NSObjectProtocol)?>(uncheckedState: nil) + private var changeCancellable: AnyCancellable? + private var licenseCancellable: AnyCancellable? + private var syncTask: Task? + private var hasStarted = false /// Bumped every time something other than a sync run decides the status, so a run that has been /// suspended across the network can tell whether its outcome is still the current answer. - @ObservationIgnored private var statusGeneration = 0 + private var statusGeneration = 0 init(services: AppServices = .live) { self.services = services diff --git a/TablePro/Core/Tips/FeatureTipViews.swift b/TablePro/Core/Tips/FeatureTipViews.swift index d2d22169e5..f1f24adb40 100644 --- a/TablePro/Core/Tips/FeatureTipViews.swift +++ b/TablePro/Core/Tips/FeatureTipViews.swift @@ -6,6 +6,7 @@ import SwiftUI import TipKit +@available(macOS 14.0, *) internal struct FeatureTipInline: View { let tip: TipType @@ -18,6 +19,7 @@ internal struct FeatureTipInline: View { } } +@available(macOS 14.0, *) internal struct FeatureTipPopoverAnchor: ViewModifier { let tip: TipType let isEnabled: Bool @@ -37,3 +39,18 @@ internal enum FeatureTipShortcut { AppSettingsManager.shared.keyboard.shortcut(for: action)?.displayString } } + +internal extension View { + /// The tip layer is TipKit, which is macOS 14. Older systems get the view unchanged. + @ViewBuilder + func historyTipAnchor(isEnabled: Bool) -> some View { + if #available(macOS 14.0, *) { + modifier(FeatureTipPopoverAnchor( + tip: FindPastQueriesTip(shortcut: FeatureTipShortcut.display(for: .toggleHistory)), + isEnabled: isEnabled + )) + } else { + self + } + } +} diff --git a/TablePro/Core/Tips/FeatureTips.swift b/TablePro/Core/Tips/FeatureTips.swift index 91209702ae..1b07f0ab52 100644 --- a/TablePro/Core/Tips/FeatureTips.swift +++ b/TablePro/Core/Tips/FeatureTips.swift @@ -6,6 +6,7 @@ import SwiftUI import TipKit +@available(macOS 14.0, *) internal struct KeepTableOpenTip: Tip { static let tipId = "keep-table-open" static let previewTabReplaced = Tips.Event(id: "preview-tab-replaced") @@ -33,6 +34,7 @@ internal struct KeepTableOpenTip: Tip { } } +@available(macOS 14.0, *) internal struct OpenQuicklyTip: Tip { static let tipId = "open-quickly" static let sidebarTableOpened = Tips.Event(id: "sidebar-table-opened") @@ -62,6 +64,7 @@ internal struct OpenQuicklyTip: Tip { } } +@available(macOS 14.0, *) internal struct FindPastQueriesTip: Tip { static let tipId = "find-past-queries" static let editorQueryRan = Tips.Event(id: "editor-query-ran") @@ -117,6 +120,7 @@ internal enum FeatureTipCopy { } } +@available(macOS 14.0, *) internal enum FeatureTipCatalog { static var ids: [String] { [KeepTableOpenTip.tipId, OpenQuicklyTip.tipId, FindPastQueriesTip.tipId] diff --git a/TablePro/Core/Tips/FeatureTipsBootstrap.swift b/TablePro/Core/Tips/FeatureTipsBootstrap.swift index bf07de510a..262a3bba51 100644 --- a/TablePro/Core/Tips/FeatureTipsBootstrap.swift +++ b/TablePro/Core/Tips/FeatureTipsBootstrap.swift @@ -7,6 +7,7 @@ import Foundation import os import TipKit +@available(macOS 14.0, *) @MainActor internal enum FeatureTipsBootstrap { private static let logger = Logger(subsystem: "com.TablePro", category: "FeatureTips") @@ -50,6 +51,7 @@ internal enum FeatureTipsBootstrap { } } +@available(macOS 14.0, *) @MainActor internal enum FeatureTipSignals { static func sidebarTableOpened() { diff --git a/TablePro/Core/UsersRoles/PrincipalChangeManager.swift b/TablePro/Core/UsersRoles/PrincipalChangeManager.swift index e886d2491e..a9ff287037 100644 --- a/TablePro/Core/UsersRoles/PrincipalChangeManager.swift +++ b/TablePro/Core/UsersRoles/PrincipalChangeManager.swift @@ -1,28 +1,26 @@ +import Combine import Foundation -import Observation import TableProPluginKit @MainActor -@Observable -final class PrincipalChangeManager { - private(set) var principals: [PluginPrincipalInfo] = [] - private(set) var catalog: PluginPrivilegeCatalog? +final class PrincipalChangeManager: ObservableObject { + @Published private(set) var principals: [PluginPrincipalInfo] = [] + @Published private(set) var catalog: PluginPrivilegeCatalog? - private(set) var baselineGrants: [PluginPrincipalRef: [PluginGrantInfo]] = [:] - private(set) var grantDeltas: [PluginPrincipalRef: PrincipalGrantDelta] = [:] + @Published private(set) var baselineGrants: [PluginPrincipalRef: [PluginGrantInfo]] = [:] + @Published private(set) var grantDeltas: [PluginPrincipalRef: PrincipalGrantDelta] = [:] - private(set) var pendingCreates: [PluginPrincipalDefinition] = [] - private(set) var pendingDrops: [PluginPrincipalRef: PluginPrincipalDropOptions] = [:] - private(set) var pendingPasswords: [PluginPrincipalRef: String] = [:] - private(set) var pendingAlters: [PluginPrincipalRef: PluginPrincipalDefinition] = [:] + @Published private(set) var pendingCreates: [PluginPrincipalDefinition] = [] + @Published private(set) var pendingDrops: [PluginPrincipalRef: PluginPrincipalDropOptions] = [:] + @Published private(set) var pendingPasswords: [PluginPrincipalRef: String] = [:] + @Published private(set) var pendingAlters: [PluginPrincipalRef: PluginPrincipalDefinition] = [:] - private(set) var changeCount = 0 - private(set) var grantClosureVersion = 0 + @Published private(set) var changeCount = 0 + @Published private(set) var grantClosureVersion = 0 /// `groupsByEvent` is off: with it on, NSUndoManager coalesces every registration made in the /// same run-loop event into one group, so undo granularity would depend on how fast the user /// clicked. Each mutation opens and closes its own group instead. - @ObservationIgnored let undoManager: UndoManager = { let manager = UndoManager() manager.groupsByEvent = false @@ -30,14 +28,11 @@ final class PrincipalChangeManager { return manager }() - @ObservationIgnored - private var baselineKeys: [PluginPrincipalRef: Set] = [:] + @Published private var baselineKeys: [PluginPrincipalRef: Set] = [:] - @ObservationIgnored - private var closureCache: [PluginPrincipalRef: Set] = [:] + @Published private var closureCache: [PluginPrincipalRef: Set] = [:] - @ObservationIgnored - var cascades: (PluginPrivilegeScope, PluginPrivilegeScope) -> Bool = { _, _ in false } + @Published var cascades: (PluginPrivilegeScope, PluginPrivilegeScope) -> Bool = { _, _ in false } var hasChanges: Bool { changeCount > 0 } diff --git a/TablePro/Core/UsersRoles/PrivilegeTreeModel.swift b/TablePro/Core/UsersRoles/PrivilegeTreeModel.swift index 13e99ace5b..e9a92979bb 100644 --- a/TablePro/Core/UsersRoles/PrivilegeTreeModel.swift +++ b/TablePro/Core/UsersRoles/PrivilegeTreeModel.swift @@ -1,34 +1,28 @@ +import Combine import Foundation -import Observation import TableProPluginKit @MainActor -@Observable -final class PrivilegeTreeModel { +final class PrivilegeTreeModel: ObservableObject { enum Mode: Equatable { case hierarchy case granted case searchResults } - private(set) var roots: [PrivilegeNode] = [] - private(set) var mode: Mode = .hierarchy - private(set) var structureVersion = 0 + @Published private(set) var roots: [PrivilegeNode] = [] + @Published private(set) var mode: Mode = .hierarchy + @Published private(set) var structureVersion = 0 - @ObservationIgnored - private var databases: [String] = [] + @Published private var databases: [String] = [] - @ObservationIgnored - private var hasServerScope = false + @Published private var hasServerScope = false - @ObservationIgnored - private var restrictsBrowsing = false + @Published private var restrictsBrowsing = false - @ObservationIgnored - private var currentDatabase: String? + @Published private var currentDatabase: String? - @ObservationIgnored - private var loader: PrincipalListLoader? + @Published private var loader: PrincipalListLoader? func configure( databases: [String], diff --git a/TablePro/Core/Vim/VimCursorManager.swift b/TablePro/Core/Vim/VimCursorManager.swift index e181d6a530..0b06236f07 100644 --- a/TablePro/Core/Vim/VimCursorManager.swift +++ b/TablePro/Core/Vim/VimCursorManager.swift @@ -194,9 +194,10 @@ final class VimCursorManager { DispatchQueue.main.async(execute: workItem) } - /// Hide the system I-beam cursor (NSTextInsertionIndicator on macOS 14+) + /// Hide the system I-beam cursor. `NSTextInsertionIndicator` is macOS 14; before it the + /// text view drew the caret itself and there is no indicator subview to hide. private func hideSystemCursor() { - guard let textView else { return } + guard #available(macOS 14.0, *), let textView else { return } for subview in textView.subviews { if let indicator = subview as? NSTextInsertionIndicator { indicator.displayMode = .hidden @@ -206,7 +207,7 @@ final class VimCursorManager { /// Restore the system I-beam cursor to automatic display private func showSystemCursor() { - guard let textView else { return } + guard #available(macOS 14.0, *), let textView else { return } for subview in textView.subviews { if let indicator = subview as? NSTextInsertionIndicator { indicator.displayMode = .automatic diff --git a/TablePro/Extensions/AccessibilityAnnouncement.swift b/TablePro/Extensions/AccessibilityAnnouncement.swift new file mode 100644 index 0000000000..c6fad22bd5 --- /dev/null +++ b/TablePro/Extensions/AccessibilityAnnouncement.swift @@ -0,0 +1,27 @@ +// +// AccessibilityAnnouncement.swift +// TablePro +// + +import AppKit +import SwiftUI + +/// `AccessibilityNotification.Announcement` is macOS 14. The fallback posts the same +/// `announcementRequested` notification AppKit has taken since 10.7, so VoiceOver hears +/// the identical string either way. +internal enum AccessibilityAnnouncement { + internal static func post(_ message: String) { + if #available(macOS 14.0, *) { + AccessibilityNotification.Announcement(message).post() + return + } + NSAccessibility.post( + element: NSApp as Any, + notification: .announcementRequested, + userInfo: [ + .announcement: message, + .priority: NSAccessibilityPriorityLevel.high.rawValue, + ] + ) + } +} diff --git a/TablePro/Extensions/ButtonStyle+AccessoryBarCompat.swift b/TablePro/Extensions/ButtonStyle+AccessoryBarCompat.swift new file mode 100644 index 0000000000..524b904b6c --- /dev/null +++ b/TablePro/Extensions/ButtonStyle+AccessoryBarCompat.swift @@ -0,0 +1,30 @@ +// +// ButtonStyle+AccessoryBarCompat.swift +// TablePro +// + +import SwiftUI + +internal extension View { + /// `.accessoryBarAction` is macOS 14. `.link` is the closest thing macOS 13 offers for a + /// borderless action sitting in a bar: the same plain, tinted label with no button chrome. + @ViewBuilder + func accessoryBarActionStyle() -> some View { + if #available(macOS 14.0, *) { + buttonStyle(.accessoryBarAction) + } else { + buttonStyle(.link) + } + } + + /// `.accessoryBar` is macOS 14. `.borderless` is what macOS 13 offers for the same shape: a + /// label with no resting chrome that still takes the whole control as its hit area. + @ViewBuilder + func accessoryBarStyle() -> some View { + if #available(macOS 14.0, *) { + buttonStyle(.accessoryBar) + } else { + buttonStyle(.borderless) + } + } +} diff --git a/TablePro/Extensions/NSColor+QuaternaryFill.swift b/TablePro/Extensions/NSColor+QuaternaryFill.swift new file mode 100644 index 0000000000..6013d400de --- /dev/null +++ b/TablePro/Extensions/NSColor+QuaternaryFill.swift @@ -0,0 +1,27 @@ +// +// NSColor+QuaternaryFill.swift +// TablePro +// + +import AppKit + +internal extension NSColor { + /// `quaternarySystemFill` is macOS 14. The fallback tracks the label colour rather than + /// naming a fixed grey, so it follows the appearance and the accessibility contrast + /// setting the way the system fill does. + static var quaternaryFill: NSColor { + if #available(macOS 14.0, *) { + return .quaternarySystemFill + } + return .labelColor.withAlphaComponent(0.05) + } + + /// `tertiarySystemFill` is macOS 14. Same shape as `quaternaryFill`: the fallback tracks + /// the label colour so it follows appearance and contrast settings. + static var tertiaryFill: NSColor { + if #available(macOS 14.0, *) { + return .tertiarySystemFill + } + return .labelColor.withAlphaComponent(0.08) + } +} diff --git a/TablePro/Extensions/NSMenuItem+SectionHeader.swift b/TablePro/Extensions/NSMenuItem+SectionHeader.swift new file mode 100644 index 0000000000..412ff2c225 --- /dev/null +++ b/TablePro/Extensions/NSMenuItem+SectionHeader.swift @@ -0,0 +1,26 @@ +// +// NSMenuItem+SectionHeader.swift +// TablePro +// + +import AppKit + +internal extension NSMenuItem { + /// `sectionHeader(title:)` is macOS 14. The fallback is what AppKit menus used before it: + /// a disabled item carrying the title, which reads as a header and takes no clicks. + static func sectionHeaderCompat(title: String) -> NSMenuItem { + if #available(macOS 14.0, *) { + return .sectionHeader(title: title) + } + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") + item.isEnabled = false + item.attributedTitle = NSAttributedString( + string: title, + attributes: [ + .font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize, weight: .semibold), + .foregroundColor: NSColor.secondaryLabelColor, + ] + ) + return item + } +} diff --git a/TablePro/Extensions/NSWindow+FirstFrame.swift b/TablePro/Extensions/NSWindow+FirstFrame.swift index 5e4cbb7349..4facce23e8 100644 --- a/TablePro/Extensions/NSWindow+FirstFrame.swift +++ b/TablePro/Extensions/NSWindow+FirstFrame.swift @@ -23,10 +23,22 @@ internal extension NSWindow { body() return } + guard #available(macOS 14.0, *) else { + /// `NSView.displayLink(target:selector:)` and `CADisplayLink` are both macOS 14. + /// The closest thing 13 has is the transaction's completion plus one hop: the + /// commit has happened, so the frame is with the WindowServer even though this + /// cannot wait for it to be presented. It is an approximation, and the caller is + /// a measurement rather than a correctness gate. + CATransaction.setCompletionBlock { + DispatchQueue.main.async { MainActor.assumeIsolated(body) } + } + return + } FirstFrameObserver.observe(view, then: body) } } +@available(macOS 14.0, *) @MainActor private final class FirstFrameObserver: NSObject { private static var live: Set = [] diff --git a/TablePro/Extensions/ObservableObject+MainActorSink.swift b/TablePro/Extensions/ObservableObject+MainActorSink.swift new file mode 100644 index 0000000000..821f58609c --- /dev/null +++ b/TablePro/Extensions/ObservableObject+MainActorSink.swift @@ -0,0 +1,24 @@ +// +// ObservableObject+MainActorSink.swift +// TablePro +// + +import Combine +import Foundation + +internal extension ObservableObject { + /// Stands in for `withObservationTracking`, which is macOS 14. Two differences the call + /// sites have to live with, and one this hides. + /// + /// `withObservationTracking` woke only for the properties its closure read; this wakes for + /// any published change on the object, so a caller that needs the old narrowing has to + /// compare values itself. And `objectWillChange` fires *before* the value lands, which is + /// why the delivery hops a run-loop turn: a callback that reads the new value would + /// otherwise read the old one. `withObservationTracking` also fired once and had to be + /// re-armed by hand; a sink stays armed for as long as its cancellable is held. + func onMainActorChange(_ body: @escaping () -> Void) -> AnyCancellable { + objectWillChange + .receive(on: RunLoop.main) + .sink { _ in body() } + } +} diff --git a/TablePro/Extensions/View+AlternatingRowsCompat.swift b/TablePro/Extensions/View+AlternatingRowsCompat.swift new file mode 100644 index 0000000000..0bf712aa95 --- /dev/null +++ b/TablePro/Extensions/View+AlternatingRowsCompat.swift @@ -0,0 +1,19 @@ +// +// View+AlternatingRowsCompat.swift +// TablePro +// + +import SwiftUI + +internal extension View { + /// `alternatingRowBackgrounds()` is macOS 14. Before it a SwiftUI `Table` drew a plain + /// background, which is what macOS 13 gets here. + @ViewBuilder + func alternatingRowBackgroundsCompat() -> some View { + if #available(macOS 14.0, *) { + alternatingRowBackgrounds(.enabled) + } else { + self + } + } +} diff --git a/TablePro/Extensions/View+ChartSelectionCompat.swift b/TablePro/Extensions/View+ChartSelectionCompat.swift new file mode 100644 index 0000000000..9f1fc6ba76 --- /dev/null +++ b/TablePro/Extensions/View+ChartSelectionCompat.swift @@ -0,0 +1,20 @@ +// +// View+ChartSelectionCompat.swift +// TablePro +// + +import Charts +import SwiftUI + +internal extension View { + /// `chartXSelection` is macOS 14. Before it a chart could not report a selection back, so + /// the binding simply never fires and the callout never appears; the chart still draws. + @ViewBuilder + func chartXSelectionCompat(value: Binding) -> some View { + if #available(macOS 14.0, *) { + chartXSelection(value: value) + } else { + self + } + } +} diff --git a/TablePro/Extensions/View+OnValueChange.swift b/TablePro/Extensions/View+OnValueChange.swift new file mode 100644 index 0000000000..0af9fcad63 --- /dev/null +++ b/TablePro/Extensions/View+OnValueChange.swift @@ -0,0 +1,35 @@ +// +// View+OnValueChange.swift +// TablePro +// + +import SwiftUI + +internal extension View { + /// The two-value `onChange(of:_:)` is macOS 14. This keeps the previous value in local + /// state so the single-value form can still report what it was. + func onValueChange( + of value: Value, + _ action: @escaping (Value, Value) -> Void + ) -> some View { + modifier(PairedValueChangeModifier(value: value, action: action)) + } +} + +private struct PairedValueChangeModifier: ViewModifier { + let value: Value + let action: (Value, Value) -> Void + + @State private var previous: Value? + + func body(content: Content) -> some View { + content + .onAppear { previous = value } + .onChange(of: value) { current in + let old = previous ?? current + previous = current + guard old != current else { return } + action(old, current) + } + } +} diff --git a/TablePro/Extensions/View+ScrollBounceCompat.swift b/TablePro/Extensions/View+ScrollBounceCompat.swift new file mode 100644 index 0000000000..007c9e14a8 --- /dev/null +++ b/TablePro/Extensions/View+ScrollBounceCompat.swift @@ -0,0 +1,29 @@ +// +// View+ScrollBounceCompat.swift +// TablePro +// + +import SwiftUI + +internal extension View { + /// `scrollBounceBehavior` arrived in macOS 13.3, and the deployment target is 13.0. Below + /// that the banner's scroll view bounces even when its content fits, which is cosmetic. + @ViewBuilder + func scrollBounceBasedOnSize() -> some View { + if #available(macOS 13.3, *) { + scrollBounceBehavior(.basedOnSize) + } else { + self + } + } + + /// The `axes:` overload, same availability floor. + @ViewBuilder + func scrollBounceBasedOnSize(axes: Axis.Set) -> some View { + if #available(macOS 13.3, *) { + scrollBounceBehavior(.basedOnSize, axes: axes) + } else { + self + } + } +} diff --git a/TablePro/Extensions/View+SymbolEffectCompat.swift b/TablePro/Extensions/View+SymbolEffectCompat.swift new file mode 100644 index 0000000000..ece7b23ac0 --- /dev/null +++ b/TablePro/Extensions/View+SymbolEffectCompat.swift @@ -0,0 +1,47 @@ +// +// View+SymbolEffectCompat.swift +// TablePro +// + +import SwiftUI + +internal extension View { + /// `contentTransition(.symbolEffect(.replace))` is macOS 14. On 13 the symbol swaps + /// without the morph, which is what the view did before the effect was added. + @ViewBuilder + func symbolReplaceTransition() -> some View { + if #available(macOS 14.0, *) { + contentTransition(.symbolEffect(.replace)) + } else { + self + } + } + + /// `symbolEffect(.pulse, isActive:)` is macOS 14. The fallback pulses the opacity, so a + /// running sync still reads as running rather than as a static icon. + @ViewBuilder + func pulsingSymbol(isActive: Bool) -> some View { + if #available(macOS 14.0, *) { + symbolEffect(.pulse, options: .repeating, isActive: isActive) + } else { + modifier(OpacityPulse(isActive: isActive)) + } + } +} + +private struct OpacityPulse: ViewModifier { + let isActive: Bool + + @State private var dimmed = false + + func body(content: Content) -> some View { + content + .opacity(isActive && dimmed ? 0.35 : 1) + .animation( + isActive ? .easeInOut(duration: 0.8).repeatForever(autoreverses: true) : .default, + value: dimmed + ) + .onAppear { dimmed = isActive } + .onChange(of: isActive) { active in dimmed = active } + } +} diff --git a/TablePro/Models/Connection/ConnectionToolbarState.swift b/TablePro/Models/Connection/ConnectionToolbarState.swift index 9c5fe226b4..699741f87f 100644 --- a/TablePro/Models/Connection/ConnectionToolbarState.swift +++ b/TablePro/Models/Connection/ConnectionToolbarState.swift @@ -6,7 +6,7 @@ // import AppKit -import Observation +import Combine import SwiftUI import TableProPluginKit @@ -45,25 +45,24 @@ enum ToolbarConnectionState: Equatable { /// Whether anything is running is NOT here. That is derived from `TabExecutionRegistry`, which is /// the only thing that knows, and a stored copy of it on this object is what let the titlebar /// report a query that had already ended (#2342). Do not reintroduce one. -@Observable @MainActor -final class ConnectionToolbarState { +final class ConnectionToolbarState: ObservableObject { // MARK: - Connection Info /// Database type (MySQL, MariaDB, PostgreSQL, SQLite) - var databaseType: DatabaseType = .mysql + @Published var databaseType: DatabaseType = .mysql /// Active database (always meaningful). For schema-grouped engines like SQL Server, /// this is the SQL Server database (e.g. "Sales"); the active schema lives in /// `currentSchema`, and the toolbar shows both. - var currentDatabase: String = "" + @Published var currentDatabase: String = "" /// Active schema for engines that browse one schema at a time. Nil for `.byDatabase` and /// `.flat` engines, where the database is the only unit, and until the schema resolves. - var currentSchema: String? + @Published var currentSchema: String? /// Current connection state - var connectionState: ToolbarConnectionState = .disconnected + @Published var connectionState: ToolbarConnectionState = .disconnected // MARK: - Query Execution @@ -76,7 +75,7 @@ final class ConnectionToolbarState { /// One entry per tab, not one slot. A single slot meant any tab finishing a query erased the /// duration another tab was still showing, because the reader asks per tab and a slot tagged /// with someone else answers nil. - private(set) var queryTimings: [UUID: PluginQueryTiming] = [:] + @Published private(set) var queryTimings: [UUID: PluginQueryTiming] = [:] /// The one writer, so a duration and the tab that produced it cannot drift apart. func recordQueryTiming(_ timing: PluginQueryTiming?, for tabId: UUID?) { @@ -102,39 +101,39 @@ final class ConnectionToolbarState { // MARK: - Future Expansion /// Safe mode level for this connection - var safeModeLevel: SafeModeLevel = .silent + @Published var safeModeLevel: SafeModeLevel = .silent var isReadOnly: Bool { safeModeLevel == .readOnly } /// Whether the current tab is a table tab (enables filter/sort actions) - var isTableTab: Bool = false + @Published var isTableTab: Bool = false /// Whether the results panel is collapsed - var isResultsCollapsed: Bool = false + @Published var isResultsCollapsed: Bool = false /// Whether there are pending changes (data grid or file) - var hasPendingChanges: Bool = false + @Published var hasPendingChanges: Bool = false /// Whether there are pending data grid changes (for SQL preview button) - var hasDataPendingChanges: Bool = false + @Published var hasDataPendingChanges: Bool = false /// Whether the structure view has pending schema changes - var hasStructureChanges: Bool = false + @Published var hasStructureChanges: Bool = false /// Whether the Create Table tab has a committable definition (name + valid column) - var hasCreateTablePending: Bool = false + @Published var hasCreateTablePending: Bool = false - var hasPrincipalChanges: Bool = false + @Published var hasPrincipalChanges: Bool = false /// Whether the current editor has non-empty query text - var hasQueryText: Bool = false + @Published var hasQueryText: Bool = false /// Whether the selected tab is a query tab. `isTableTab` cannot answer this: a structure, /// dashboard or diagram tab is neither, and the Run item has to be disabled on all of them. var isQueryTab: Bool = false /// SQL statements rendered in the SQL preview sheet - var previewStatements: [String] = [] + @Published var previewStatements: [String] = [] // MARK: - Initialization diff --git a/TablePro/Models/Query/QueryTabManager.swift b/TablePro/Models/Query/QueryTabManager.swift index 6a6eba9fc7..657b736c90 100644 --- a/TablePro/Models/Query/QueryTabManager.swift +++ b/TablePro/Models/Query/QueryTabManager.swift @@ -5,13 +5,12 @@ import Combine import Foundation -import Observation import os /// Manager for query tabs -@MainActor @Observable -final class QueryTabManager { - var tabs: [QueryTab] = [] { +@MainActor +final class QueryTabManager: ObservableObject { + @Published var tabs: [QueryTab] = [] { didSet { _tabIndexMapDirty = true if oldValue.map(\.id) != tabs.map(\.id) { @@ -22,17 +21,17 @@ final class QueryTabManager { } } - var selectedTabId: UUID? + @Published var selectedTabId: UUID? - var tabStructureVersion: Int = 0 + @Published var tabStructureVersion: Int = 0 - @ObservationIgnored var pendingFocusTabId: UUID? + var pendingFocusTabId: UUID? - @ObservationIgnored private var _tabIndexMap: [UUID: Int] = [:] - @ObservationIgnored private var _tabIndexMapDirty = true + private var _tabIndexMap: [UUID: Int] = [:] + private var _tabIndexMapDirty = true - @ObservationIgnored private let globalTabsProvider: () -> [QueryTab] - @ObservationIgnored private weak var tabSessionRegistry: TabSessionRegistry? + private let globalTabsProvider: () -> [QueryTab] + private weak var tabSessionRegistry: TabSessionRegistry? init( globalTabsProvider: @escaping () -> [QueryTab] = { [] }, @@ -257,14 +256,14 @@ final class QueryTabManager { } } - var onTableOpened: ((_ tableName: String, _ schemaName: String?, _ databaseName: String, _ isView: Bool, _ isPreview: Bool) -> Void)? + @Published var onTableOpened: ((_ tableName: String, _ schemaName: String?, _ databaseName: String, _ isView: Bool, _ isPreview: Bool) -> Void)? /// Fired the instant a tab stops being about the table it was about. Whoever owns execution /// listens here rather than at the navigation call sites, because a retarget that forgets to /// invalidate is exactly how a finished query paints its rows into a tab showing something else. - var onTabRetargeted: ((UUID) -> Void)? + @Published var onTabRetargeted: ((UUID) -> Void)? - var onTableSchemaResolved: ((_ tableName: String, _ databaseName: String, _ schemaName: String) -> Void)? + @Published var onTableSchemaResolved: ((_ tableName: String, _ databaseName: String, _ schemaName: String) -> Void)? private func notifyTableOpened( tableName: String, schemaName: String?, databaseName: String, isView: Bool, isPreview: Bool diff --git a/TablePro/Models/Query/QueryTabState.swift b/TablePro/Models/Query/QueryTabState.swift index 11c9472395..b4acd37d97 100644 --- a/TablePro/Models/Query/QueryTabState.swift +++ b/TablePro/Models/Query/QueryTabState.swift @@ -3,12 +3,13 @@ // TablePro // +import Combine import Foundation import TableProPluginKit -@MainActor @Observable -final class GridSelectionState { - var indices: Set = [] +@MainActor +final class GridSelectionState: ObservableObject { + @Published var indices: Set = [] } /// Type of tab diff --git a/TablePro/Models/Query/ResultSet.swift b/TablePro/Models/Query/ResultSet.swift index 75f02b3234..405ca06fab 100644 --- a/TablePro/Models/Query/ResultSet.swift +++ b/TablePro/Models/Query/ResultSet.swift @@ -5,8 +5,8 @@ // A single result set from one SQL statement execution. // +import Combine import Foundation -import Observation import os /// One execution's product: its rows, and the facts about how they were produced. @@ -23,24 +23,23 @@ import os /// up here (#2243). `origin` is the one of those facts that earns its place: it describes this /// result rather than the tab, and the switch reads it. @MainActor -@Observable -final class ResultSet: Identifiable { +final class ResultSet: ObservableObject, Identifiable { let id: UUID - var label: String - var tableRows: TableRows - var executionTime: TimeInterval? - var rowsAffected: Int = 0 - var errorMessage: String? - var statusMessage: String? - var isPinned: Bool = false - var isTruncated: Bool = false - var baseQuery: String? - var baseQueryParameterValues: [String?]? + @Published var label: String + @Published var tableRows: TableRows + @Published var executionTime: TimeInterval? + @Published var rowsAffected: Int = 0 + @Published var errorMessage: String? + @Published var statusMessage: String? + @Published var isPinned: Bool = false + @Published var isTruncated: Bool = false + @Published var baseQuery: String? + @Published var baseQueryParameterValues: [String?]? /// The table these rows came from, captured when the statement ran. Nil means the rows have no /// single writable table, which `ResultEditability` treats as a refusal rather than a licence /// to use whatever the tab is pointing at now. - var origin: ResultOrigin? + @Published var origin: ResultOrigin? /// The statement in the tab's query that produced these rows, kept so selecting this result can /// take the reader back to it. Nil for rows no editor statement stands behind: a table tab's @@ -48,16 +47,16 @@ final class ResultSet: Identifiable { /// /// Like `origin` this describes the result rather than the tab, and like `origin` something /// reads it, which is what earns it a place here. - var statementAnchor: StatementAnchor? + @Published var statementAnchor: StatementAnchor? /// An EXPLAIN result is a result set like any other, so it rides the same tab strip, pinning /// and history. It carries a plan instead of rows. - var queryPlan: QueryPlan? - var explainRawText: String? + @Published var queryPlan: QueryPlan? + @Published var explainRawText: String? /// Where this plan sits in the statement's saved history, so the plan pane can offer a /// comparison without asking a coordinator anything. - var explainPlanContext: QueryPlanContext? + @Published var explainPlanContext: QueryPlanContext? var isExplainResult: Bool { explainRawText != nil } diff --git a/TablePro/Models/Query/TabSession.swift b/TablePro/Models/Query/TabSession.swift index f3042969b9..558dce08ff 100644 --- a/TablePro/Models/Query/TabSession.swift +++ b/TablePro/Models/Query/TabSession.swift @@ -3,8 +3,8 @@ // TablePro // +import Combine import Foundation -import Observation /// The row buffer for one tab, and nothing else. /// @@ -19,33 +19,33 @@ import Observation /// mirrored copies, `QueryTabManager` only reconciled them on tab insert and removal, so pointing /// a tab at another table left them describing the old one, and the ones that were kept in sync /// were kept in sync for no reader (#2060). -@Observable @MainActor -final class TabSession: Identifiable { + @MainActor +final class TabSession: ObservableObject, Identifiable { let id: UUID - var tableRows: TableRows - var isEvicted: Bool + @Published var tableRows: TableRows + @Published var isEvicted: Bool /// Bumped by `TabSessionRegistry` on every mutation of `tableRows`. `TableRows` is a /// value type with no `Equatable` conformance, so views that derive expensive content /// from it key their rebuild on this counter instead of on a proxy like the row count, /// which cannot distinguish two different results of the same size. - var dataRevision: Int + @Published var dataRevision: Int /// Bumped only when the buffer is replaced wholesale, which `dataRevision` cannot express: it /// also moves for an in-place edit, and a row id survives one of those. Row ids are positional, /// so a new page hands the same ids to different rows, and anything derived per row id has to /// be dropped exactly then and kept across an edit. - var bufferEpoch: Int + @Published var bufferEpoch: Int /// Bumped when rows arrive, leave or are replaced, and never for a cell edit, which is the one /// distinction the other two counters cannot draw: `dataRevision` moves for an edit and /// `bufferEpoch` sits still for an insert or a delete. Anything that answers "which row sits at /// this display position" has to hold still across an edit and move with the row set, because an /// edit leaves the rows where they are and the grid goes on showing them there. - var rowSetRevision: Int + @Published var rowSetRevision: Int - @ObservationIgnored var viewportStage: GridViewportStage? + var viewportStage: GridViewportStage? init(id: UUID = UUID()) { self.id = id diff --git a/TablePro/Models/UI/AssistantState.swift b/TablePro/Models/UI/AssistantState.swift index f0feb0972b..3407b49cc2 100644 --- a/TablePro/Models/UI/AssistantState.swift +++ b/TablePro/Models/UI/AssistantState.swift @@ -3,6 +3,7 @@ // TablePro // +import Combine import Foundation /// The assistant surface's own state. @@ -13,14 +14,15 @@ import Foundation /// every connection window read the whole chat history off disk on the window-open path, with the /// assistant never revealed and with the feature turned off in settings. Activation is now the /// single door, and only revealing the surface or invoking an assistant command opens it. -@MainActor @Observable internal final class AssistantState { - internal var context: AssistantContext = .empty +@MainActor +internal final class AssistantState: ObservableObject { + @Published internal var context: AssistantContext = .empty - @ObservationIgnored private var activatedViewModel: AIChatViewModel? + private var activatedViewModel: AIChatViewModel? /// Observable, unlike the view model itself, so the window can seed the assistant's context the /// moment it comes into existence. The last context update ran before it did and skipped it. - internal private(set) var isActivated = false + @Published internal private(set) var isActivated = false /// Nil until something actually needs the assistant. Readers that only want to talk to a live /// assistant take this and do nothing when it is nil, rather than bringing one into existence. diff --git a/TablePro/Models/UI/HistoryPanelState.swift b/TablePro/Models/UI/HistoryPanelState.swift index bbd36db8f0..0271d82503 100644 --- a/TablePro/Models/UI/HistoryPanelState.swift +++ b/TablePro/Models/UI/HistoryPanelState.swift @@ -1,9 +1,8 @@ +import Combine import Foundation -import Observation @MainActor -@Observable -final class HistoryPanelState { +final class HistoryPanelState: ObservableObject { let connectionId: UUID var isVisible: Bool { didSet { persistIfChanged(oldValue != isVisible) } } @@ -15,7 +14,7 @@ final class HistoryPanelState { /// 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. - var searchText: String = "" + @Published var searchText: String = "" /// Device-local and shared by every connection, because pausing is a decision about this Mac /// rather than about one database. diff --git a/TablePro/Models/UI/MultiRowEditState.swift b/TablePro/Models/UI/MultiRowEditState.swift index 371e6fe8dc..4939ce9b47 100644 --- a/TablePro/Models/UI/MultiRowEditState.swift +++ b/TablePro/Models/UI/MultiRowEditState.swift @@ -6,8 +6,8 @@ // Tracks pending edits across multiple selected rows. // +import Combine import Foundation -import Observation import TableProPluginKit /// Represents the edit state for a single field across multiple rows @@ -74,34 +74,34 @@ enum FieldEditContinuity { } /// Manages edit state for multi-row editing in sidebar -@MainActor @Observable -final class MultiRowEditState { - var fields: [FieldEditState] = [] +@MainActor +final class MultiRowEditState: ObservableObject { + @Published var fields: [FieldEditState] = [] /// A field's new value, and whether it arrived a character at a time. Typing is folded into one /// undo step; choosing NULL, DEFAULT, a function or a picker value is its own step. - var onFieldChanged: ((Int, PluginCellValue, FieldEditContinuity) -> Void)? + @Published var onFieldChanged: ((Int, PluginCellValue, FieldEditContinuity) -> Void)? /// A field the selected rows disagree on, cleared back to nothing. It has no single value to /// send, so it asks for each row's own configured value instead. - var onFieldReverted: ((Int, [RowID: PluginCellValue]) -> Void)? + @Published var onFieldReverted: ((Int, [RowID: PluginCellValue]) -> Void)? /// A value window still open over a selection that has moved on. It names the rows it was /// opened for, because the fields it was opened from are gone. - var onDetachedFieldChanged: ((Int, PluginCellValue, [RowID]) -> Void)? + @Published var onDetachedFieldChanged: ((Int, PluginCellValue, [RowID]) -> Void)? - private(set) var selectedRowIndices: Set = [] + @Published private(set) var selectedRowIndices: Set = [] /// The rows an edit is staged against, captured when the selection was configured. /// /// `selectedRowIndices` are display positions, and a commit that resolves them when the /// keystroke arrives writes into whatever row the sort, the value filter or a later selection /// left at that position. - private(set) var rowIDs: [RowID] = [] + @Published private(set) var rowIDs: [RowID] = [] - private(set) var allRows: [[String?]] = [] - private(set) var columns: [String] = [] - private(set) var columnTypes: [ColumnType] = [] + @Published private(set) var allRows: [[String?]] = [] + @Published private(set) var columns: [String] = [] + @Published private(set) var columnTypes: [ColumnType] = [] var hasEdits: Bool { fields.contains { $0.hasEdit } diff --git a/TablePro/Models/UI/RowInspectorState.swift b/TablePro/Models/UI/RowInspectorState.swift index fb77126e20..bbfb3da930 100644 --- a/TablePro/Models/UI/RowInspectorState.swift +++ b/TablePro/Models/UI/RowInspectorState.swift @@ -3,15 +3,17 @@ // TablePro // +import Combine import Foundation /// The inspector surface's own state: which rendering of the row is showing, what the row is, and /// the two models that draw it. -@MainActor @Observable internal final class RowInspectorState { - @ObservationIgnored private let connectionId: UUID? - @ObservationIgnored private let defaults: UserDefaults +@MainActor +internal final class RowInspectorState: ObservableObject { + private let connectionId: UUID? + private let defaults: UserDefaults - internal var viewMode: InspectorViewMode { + @Published internal var viewMode: InspectorViewMode { didSet { guard let connectionId else { return } defaults.set(viewMode.rawValue, forKey: Self.viewModeKey(connectionId)) @@ -23,7 +25,7 @@ import Foundation /// A view's `onChange` runs after the render that already observed the new value, so the view /// drew one frame of the previous record's tree before the model caught up and moving between /// rows flickered. Writing both in the same turn means every render sees one consistent row. - internal var context: RowInspectorContext = .empty { + @Published internal var context: RowInspectorContext = .empty { didSet { guard context != oldValue else { return } jsonViewModel.update(snapshot: context.jsonRow) diff --git a/TablePro/Models/UI/SharedSidebarState.swift b/TablePro/Models/UI/SharedSidebarState.swift index c85fe7acf9..1f6af20084 100644 --- a/TablePro/Models/UI/SharedSidebarState.swift +++ b/TablePro/Models/UI/SharedSidebarState.swift @@ -7,6 +7,7 @@ // `WindowSidebarState`. // +import Combine import Foundation /// Which sidebar tab is active @@ -20,16 +21,16 @@ internal enum SidebarLayout: String, CaseIterable, Sendable { case tree } -@MainActor @Observable -final class SharedSidebarState { - var redisKeyTreeViewModel: RedisKeyTreeViewModel? +@MainActor +final class SharedSidebarState: ObservableObject { + @Published var redisKeyTreeViewModel: RedisKeyTreeViewModel? - var searchText: String = "" - var favoritesSearchText: String = "" + @Published var searchText: String = "" + @Published var favoritesSearchText: String = "" - var recentTables: [RecentTableEntry] = [] + @Published var recentTables: [RecentTableEntry] = [] - @ObservationIgnored private var pendingRecordTask: Task? + private var pendingRecordTask: Task? func recentEntries(inDatabase database: String?) -> [RecentTableEntry] { recentTables.filter { $0.database == normalizedDatabase(database) } @@ -131,7 +132,7 @@ final class SharedSidebarState { return database } - var selectedSidebarTab: SidebarTab { + @Published var selectedSidebarTab: SidebarTab { didSet { AppStorageEnvironment.shared.defaults.set( selectedSidebarTab.rawValue, @@ -140,7 +141,7 @@ final class SharedSidebarState { } } - var sidebarLayout: SidebarLayout { + @Published var sidebarLayout: SidebarLayout { didSet { AppStorageEnvironment.shared.defaults.set( sidebarLayout.rawValue, @@ -149,7 +150,7 @@ final class SharedSidebarState { } } - var databaseFilterSelected: Set { + @Published var databaseFilterSelected: Set { didSet { DatabaseTreeFilterStorage.shared.setSelectedDatabases( databaseFilterSelected, @@ -158,7 +159,7 @@ final class SharedSidebarState { } } - var favoriteDatabaseEnvironmentFilter: FavoriteDatabaseEnvironmentFilter { + @Published var favoriteDatabaseEnvironmentFilter: FavoriteDatabaseEnvironmentFilter { didSet { AppStorageEnvironment.shared.defaults.set( favoriteDatabaseEnvironmentFilter.rawValue, @@ -167,7 +168,7 @@ final class SharedSidebarState { } } - var selectedFavorite: FavoriteSelection? { + @Published var selectedFavorite: FavoriteSelection? { didSet { guard oldValue != selectedFavorite else { return } let key = SidebarPersistenceKey.selectedFavorite(connectionId: connectionId) diff --git a/TablePro/Models/UI/TrailingPaneState.swift b/TablePro/Models/UI/TrailingPaneState.swift index 40854b2ead..db3cc58b7c 100644 --- a/TablePro/Models/UI/TrailingPaneState.swift +++ b/TablePro/Models/UI/TrailingPaneState.swift @@ -3,6 +3,7 @@ // TablePro // +import Combine import Foundation import os @@ -11,15 +12,16 @@ import os /// This is the owner, not a third concern. The inspector knows nothing about the assistant and the /// assistant nothing about the row, which is the whole point of the split; something still has to /// say which of them the pane is currently drawing and to persist that per connection. -@MainActor @Observable internal final class TrailingPaneState { - @ObservationIgnored private let _didTeardown = OSAllocatedUnfairLock(initialState: false) - @ObservationIgnored private let connectionId: UUID? - @ObservationIgnored private let defaults: UserDefaults +@MainActor +internal final class TrailingPaneState: ObservableObject { + private let _didTeardown = OSAllocatedUnfairLock(initialState: false) + private let connectionId: UUID? + private let defaults: UserDefaults /// Which surface the pane draws when it is revealed. Revealing is a separate question, owned by /// the split view controller: the pane can be collapsed with a surface still remembered here, /// which is what lets a reveal put back what the user was last looking at. - internal var surface: TrailingPaneSurface { + @Published internal var surface: TrailingPaneSurface { didSet { guard let connectionId else { return } defaults.set(surface.rawValue, forKey: Self.surfaceKey(connectionId)) diff --git a/TablePro/Models/UI/WindowSidebarState.swift b/TablePro/Models/UI/WindowSidebarState.swift index d1d08ce622..4e7f73555e 100644 --- a/TablePro/Models/UI/WindowSidebarState.swift +++ b/TablePro/Models/UI/WindowSidebarState.swift @@ -3,8 +3,8 @@ // TablePro // +import Combine import Foundation -import Observation import TableProPluginKit struct DatabaseSchemaKey: Hashable, Sendable, Codable { @@ -19,13 +19,12 @@ struct DatabaseTableKey: Hashable, Sendable, Codable { } @MainActor -@Observable -internal final class WindowSidebarState { - @ObservationIgnored private let connectionId: UUID? - @ObservationIgnored private let defaults: UserDefaults - @ObservationIgnored private var isLoaded = false +internal final class WindowSidebarState: ObservableObject { + private let connectionId: UUID? + private let defaults: UserDefaults + private var isLoaded = false - var selectedTables: Set = [] + @Published var selectedTables: Set = [] /// How many rows are selected, which is not the same as how many tables. A table selected /// alongside a schema is an extension of a selection, not a pick, and the set of tables alone @@ -33,7 +32,7 @@ internal final class WindowSidebarState { /// /// Only the object tree can select a row that is not a table, so every other writer goes /// through `selectTables(_:)` and the two stay consistent by construction. - private(set) var selectedRowCount = 0 + @Published private(set) var selectedRowCount = 0 func selectTables(_ tables: Set) { select(tables: tables, rowCount: tables.count) @@ -56,11 +55,11 @@ internal final class WindowSidebarState { var acceptsObjectMarkRefresh: Bool { selectedTables.isEmpty && selectedRowCount == 0 } - var expandedTreeSchemas: Set = [] { didSet { persistExpansion() } } - var expandedTreeDatabases: Set = [] { didSet { persistExpansion() } } - var expandedTreeDatabaseSchemas: Set = [] { didSet { persistExpansion() } } - var expandedTreeTables: Set = [] { didSet { persistExpansion() } } - private(set) var treeObjectGroupExpansion: [DatabaseTreeObjectGroup: Bool] = [:] { + @Published var expandedTreeSchemas: Set = [] { didSet { persistExpansion() } } + @Published var expandedTreeDatabases: Set = [] { didSet { persistExpansion() } } + @Published var expandedTreeDatabaseSchemas: Set = [] { didSet { persistExpansion() } } + @Published var expandedTreeTables: Set = [] { didSet { persistExpansion() } } + @Published private(set) var treeObjectGroupExpansion: [DatabaseTreeObjectGroup: Bool] = [:] { didSet { persistExpansion() } } @@ -79,7 +78,7 @@ internal final class WindowSidebarState { /// An all-empty expansion set means "the user collapsed everything" just as much as it /// means "the user has never opened this tree", and seeding on the former would reopen /// nodes they deliberately closed. This records that the seed already happened. - private(set) var didSeedExpansion = false + @Published private(set) var didSeedExpansion = false /// Opens the tree on the connection's current location the first time it is shown, so a /// database whose objects all sit under one schema is not a row of closed triangles. diff --git a/TablePro/Theme/ThemeEngine.swift b/TablePro/Theme/ThemeEngine.swift index 506933d46f..6946a016b1 100644 --- a/TablePro/Theme/ThemeEngine.swift +++ b/TablePro/Theme/ThemeEngine.swift @@ -9,7 +9,6 @@ import AppKit import Combine import Foundation -import Observation import os import SwiftUI import TableProEditorKit @@ -67,23 +66,22 @@ internal struct DataGridFontCacheResolved { // MARK: - ThemeEngine -@Observable @MainActor -internal final class ThemeEngine { +internal final class ThemeEngine: ObservableObject { static let shared = ThemeEngine() // MARK: - Active Theme - private(set) var activeTheme: ThemeDefinition + @Published private(set) var activeTheme: ThemeDefinition /// Pre-resolved colors (rebuilt on theme change) - private(set) var colors: ResolvedThemeColors + @Published private(set) var colors: ResolvedThemeColors /// Cached editor fonts - private(set) var editorFonts: EditorFontCache + @Published private(set) var editorFonts: EditorFontCache /// Cached data grid fonts - private(set) var dataGridFonts: DataGridFontCacheResolved + @Published private(set) var dataGridFonts: DataGridFontCacheResolved // MARK: - Stored Value Font @@ -100,22 +98,22 @@ internal final class ThemeEngine { // MARK: - Available Themes - private(set) var availableThemes: [ThemeDefinition] + @Published private(set) var availableThemes: [ThemeDefinition] // MARK: - Editor Behavioral Settings (read from AppSettingsManager) /// These are not theme properties but are needed by makeEditorTheme() - @ObservationIgnored var highlightCurrentLine: Bool = true - @ObservationIgnored var highlightCurrentStatement: Bool = true - @ObservationIgnored var showLineNumbers: Bool = true - @ObservationIgnored var tabWidth: Int = 4 - @ObservationIgnored var wordWrap: Bool = false + var highlightCurrentLine: Bool = true + var highlightCurrentStatement: Bool = true + var showLineNumbers: Bool = true + var tabWidth: Int = 4 + var wordWrap: Bool = false // MARK: - Private - @ObservationIgnored nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "ThemeEngine") - @ObservationIgnored private var accessibilityObserver: NSObjectProtocol? - @ObservationIgnored private var lastAccessibilityScale: CGFloat = 1.0 + nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "ThemeEngine") + private var accessibilityObserver: NSObjectProtocol? + private var lastAccessibilityScale: CGFloat = 1.0 // MARK: - Init @@ -337,11 +335,11 @@ internal final class ThemeEngine { // MARK: - Appearance - @ObservationIgnored private(set) var appearanceMode: AppAppearanceMode = .auto - private(set) var effectiveAppearance: ThemeAppearance = .light - @ObservationIgnored private var currentLightThemeId: String = "tablepro.default-light" - @ObservationIgnored private var currentDarkThemeId: String = "tablepro.default-dark" - @ObservationIgnored private var systemAppearanceObservation: NSKeyValueObservation? + private(set) var appearanceMode: AppAppearanceMode = .auto + @Published private(set) var effectiveAppearance: ThemeAppearance = .light + private var currentLightThemeId: String = "tablepro.default-light" + private var currentDarkThemeId: String = "tablepro.default-dark" + private var systemAppearanceObservation: NSKeyValueObservation? /// Central entry point: resolves effective appearance, picks the correct theme, activates it, /// and derives NSApp.appearance from the theme's own appearance metadata. diff --git a/TablePro/Theme/ThemeRegistryInstaller.swift b/TablePro/Theme/ThemeRegistryInstaller.swift index 4a7644a43f..52f95b6c26 100644 --- a/TablePro/Theme/ThemeRegistryInstaller.swift +++ b/TablePro/Theme/ThemeRegistryInstaller.swift @@ -6,16 +6,16 @@ // Themes are pure JSON (no executable code, no .tableplugin bundles). // +import Combine import CryptoKit import Foundation import os @MainActor -@Observable -internal final class ThemeRegistryInstaller { +internal final class ThemeRegistryInstaller: ObservableObject { static let shared = ThemeRegistryInstaller() - @ObservationIgnored nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "ThemeRegistryInstaller") + nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "ThemeRegistryInstaller") private init() {} diff --git a/TablePro/ViewModels/AIChatViewModel+Streaming.swift b/TablePro/ViewModels/AIChatViewModel+Streaming.swift index 85c1a5abf4..432b05da1d 100644 --- a/TablePro/ViewModels/AIChatViewModel+Streaming.swift +++ b/TablePro/ViewModels/AIChatViewModel+Streaming.swift @@ -531,9 +531,9 @@ extension AIChatViewModel { self.streamingState = .pausedAtToolLimit(count: count) self.streamingTask = nil self.persistCurrentConversation() - AccessibilityNotification.Announcement( + AccessibilityAnnouncement.post( String(format: String(localized: "Paused after %d tool calls."), count) - ).post() + ) } } diff --git a/TablePro/ViewModels/AIChatViewModel.swift b/TablePro/ViewModels/AIChatViewModel.swift index b462c24c18..70c6773867 100644 --- a/TablePro/ViewModels/AIChatViewModel.swift +++ b/TablePro/ViewModels/AIChatViewModel.swift @@ -3,13 +3,13 @@ // TablePro // +import Combine import Foundation -import Observation import os import TableProPluginKit -@MainActor @Observable -final class AIChatViewModel { +@MainActor +final class AIChatViewModel: ObservableObject { nonisolated static let logger = Logger(subsystem: "com.TablePro", category: "AIChatViewModel") enum StreamingState { @@ -21,35 +21,35 @@ final class AIChatViewModel { case failed(AIProviderError?) } - var messages: [ChatTurn] = [] - var inputText: String = "" - var streamingState: StreamingState = .idle - var errorMessage: String? - var conversations: [AIConversation] = [] - var activeConversationID: UUID? - var showAIAccessConfirmation = false - var selectedProviderId: UUID? - var selectedModel: String? - var availableModels: [UUID: [String]] = [:] - var attachedContext: [ContextItem] = [] - var attachedImages: [ChatImageInput] = [] - var savedQueries: [SQLFavorite] = [] - - var connection: DatabaseConnection? - - @ObservationIgnored var streamFlushClock: StreamFlushClock = ContinuousStreamFlushClock() - @ObservationIgnored var streamFlushInterval: Duration = .milliseconds(50) + @Published var messages: [ChatTurn] = [] + @Published var inputText: String = "" + @Published var streamingState: StreamingState = .idle + @Published var errorMessage: String? + @Published var conversations: [AIConversation] = [] + @Published var activeConversationID: UUID? + @Published var showAIAccessConfirmation = false + @Published var selectedProviderId: UUID? + @Published var selectedModel: String? + @Published var availableModels: [UUID: [String]] = [:] + @Published var attachedContext: [ContextItem] = [] + @Published var attachedImages: [ChatImageInput] = [] + @Published var savedQueries: [SQLFavorite] = [] + + @Published var connection: DatabaseConnection? + + var streamFlushClock: StreamFlushClock = ContinuousStreamFlushClock() + var streamFlushInterval: Duration = .milliseconds(50) var tables: [TableInfo] { guard let id = connection?.id else { return [] } return services.schemaService.tables(for: id) } - var columnsByTable: [String: [ColumnInfo]] = [:] - var foreignKeysByTable: [String: [ForeignKeyInfo]] = [:] + @Published var columnsByTable: [String: [ColumnInfo]] = [:] + @Published var foreignKeysByTable: [String: [ForeignKeyInfo]] = [:] - var currentQuery: String? - var queryResults: String? + @Published var currentQuery: String? + @Published var queryResults: String? var isStreaming: Bool { switch streamingState { @@ -81,16 +81,16 @@ final class AIChatViewModel { lastError?.isRetryable ?? true } - @ObservationIgnored var pendingWalkthroughBeforeSQL: String? - @ObservationIgnored var inFlightColumnFetches: [String: Task] = [:] - @ObservationIgnored var inFlightSchemaLoad: Task? - @ObservationIgnored nonisolated(unsafe) var streamingTask: Task? - @ObservationIgnored var prepTask: Task? + var pendingWalkthroughBeforeSQL: String? + var inFlightColumnFetches: [String: Task] = [:] + var inFlightSchemaLoad: Task? + nonisolated(unsafe) var streamingTask: Task? + var prepTask: Task? - @ObservationIgnored let services: AppServices + let services: AppServices var chatStorage: AIChatStorage { services.aiChatStorage } - var sessionApprovedConnections: Set = [] - @ObservationIgnored var cachedSavedQueries: [UUID: SQLFavorite] = [:] + @Published var sessionApprovedConnections: Set = [] + var cachedSavedQueries: [UUID: SQLFavorite] = [:] static let maxMessageCount = 200 diff --git a/TablePro/ViewModels/ColumnJumpViewModel.swift b/TablePro/ViewModels/ColumnJumpViewModel.swift index ff36a31a39..b84a6d746a 100644 --- a/TablePro/ViewModels/ColumnJumpViewModel.swift +++ b/TablePro/ViewModels/ColumnJumpViewModel.swift @@ -3,11 +3,11 @@ // TablePro // +import Combine import Foundation -import Observation -@MainActor @Observable -final class ColumnJumpViewModel { +@MainActor +final class ColumnJumpViewModel: ObservableObject { struct Match: Identifiable, Equatable { let entry: GridColumnEntry let matchedIndices: [Int] @@ -16,13 +16,13 @@ final class ColumnJumpViewModel { } let entries: [GridColumnEntry] - private(set) var matches: [Match] = [] - var selectedId: String? - var searchText: String { + @Published private(set) var matches: [Match] = [] + @Published var selectedId: String? + @Published var searchText: String { didSet { refilter() } } - @ObservationIgnored private var rankedQuery: String + private var rankedQuery: String /// - Parameter cursorColumnIndex: the data index under the grid's cell cursor, which the empty /// list opens on so Return with nothing typed goes nowhere the reader is not already looking. diff --git a/TablePro/ViewModels/ConnectionDataCache.swift b/TablePro/ViewModels/ConnectionDataCache.swift index 680d8814bd..788cd6bb70 100644 --- a/TablePro/ViewModels/ConnectionDataCache.swift +++ b/TablePro/ViewModels/ConnectionDataCache.swift @@ -5,11 +5,9 @@ import Combine import Foundation -import Observation @MainActor -@Observable -internal final class ConnectionDataCache { +internal final class ConnectionDataCache: ObservableObject { private static let instances = NSMapTable( keyOptions: .strongMemory, valueOptions: .weakMemory @@ -25,14 +23,14 @@ internal final class ConnectionDataCache { let connectionId: UUID - private(set) var folders: [SQLFavoriteFolder] = [] - private(set) var favorites: [SQLFavorite] = [] - private(set) var linkedFolders: [LinkedSQLFolder] = [] - private(set) var linkedFilesByFolderId: [UUID: [LinkedSQLFavorite]] = [:] - private(set) var isInitialLoadComplete: Bool = false + @Published private(set) var folders: [SQLFavoriteFolder] = [] + @Published private(set) var favorites: [SQLFavorite] = [] + @Published private(set) var linkedFolders: [LinkedSQLFolder] = [] + @Published private(set) var linkedFilesByFolderId: [UUID: [LinkedSQLFavorite]] = [:] + @Published private(set) var isInitialLoadComplete: Bool = false - @ObservationIgnored private var cancellables: Set = [] - @ObservationIgnored private var refreshTask: Task? + private var cancellables: Set = [] + private var refreshTask: Task? private init(connectionId: UUID) { self.connectionId = connectionId diff --git a/TablePro/ViewModels/DatabaseSwitcherViewModel.swift b/TablePro/ViewModels/DatabaseSwitcherViewModel.swift index c6bc931c7c..8da54882c5 100644 --- a/TablePro/ViewModels/DatabaseSwitcherViewModel.swift +++ b/TablePro/ViewModels/DatabaseSwitcherViewModel.swift @@ -3,20 +3,20 @@ // TablePro // +import Combine import Foundation -import Observation import os import SwiftUI -@MainActor @Observable -final class DatabaseSwitcherViewModel { +@MainActor +final class DatabaseSwitcherViewModel: ObservableObject { nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "DatabaseSwitcherViewModel") - var databases: [DatabaseMetadata] = [] - var searchText = "" { + @Published var databases: [DatabaseMetadata] = [] + @Published var searchText = "" { didSet { selectedDatabase = filteredDatabases.first?.name } } - var selectedDatabases: Set = [] + @Published var selectedDatabases: Set = [] /// The keyboard path (arrows, Return) drives one row at a time, so it reads and /// writes the selection as a single value while the mouse can extend it. @@ -24,19 +24,19 @@ final class DatabaseSwitcherViewModel { get { selectedDatabases.count == 1 ? selectedDatabases.first : nil } set { selectedDatabases = newValue.map { [$0] } ?? [] } } - var isLoading = false - var errorMessage: String? - var showPreview = false + @Published var isLoading = false + @Published var errorMessage: String? + @Published var showPreview = false let switchTarget: ContainerSwitchTarget private let connectionId: UUID private let currentDatabase: String? private let databaseType: DatabaseType - @ObservationIgnored private let services: AppServices + private let services: AppServices private let sidebarState: SharedSidebarState? - @ObservationIgnored private var hasLoadedOnce = false - @ObservationIgnored private var loadToken: UUID? + private var hasLoadedOnce = false + private var loadToken: UUID? /// The sidebar's database filter narrows a database list only. In schema mode these rows are /// schemas, and the filter names databases. diff --git a/TablePro/ViewModels/ERDiagramViewModel.swift b/TablePro/ViewModels/ERDiagramViewModel.swift index 2da1a04a2c..55dc930c35 100644 --- a/TablePro/ViewModels/ERDiagramViewModel.swift +++ b/TablePro/ViewModels/ERDiagramViewModel.swift @@ -6,8 +6,7 @@ import SwiftUI import TableProPluginKit @MainActor -@Observable -final class ERDiagramViewModel { +final class ERDiagramViewModel: ObservableObject { nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "ERDiagram") // MARK: - Configuration @@ -55,22 +54,22 @@ final class ERDiagramViewModel { } } - var loadState: LoadState = .loading - var needsInitialFit = true - var graph: ERDiagramGraph = .empty - var isCompactMode = false { + @Published var loadState: LoadState = .loading + @Published var needsInitialFit = true + @Published var graph: ERDiagramGraph = .empty + @Published var isCompactMode = false { didSet { rebuildVisibleGraph() } } - var collapseJunctions = true { + @Published var collapseJunctions = true { didSet { rebuildVisibleGraph() } } var hasJunctionTables: Bool { !fullGraph.junctionTableIds.isEmpty } - @ObservationIgnored private var fullGraph: ERDiagramGraph = .empty - @ObservationIgnored private var allColumns: [String: [ColumnInfo]] = [:] - @ObservationIgnored private var allForeignKeys: [String: [ForeignKeyInfo]] = [:] + private var fullGraph: ERDiagramGraph = .empty + private var allColumns: [String: [ColumnInfo]] = [:] + private var allForeignKeys: [String: [ForeignKeyInfo]] = [:] // MARK: - Canvas Viewport @@ -80,37 +79,37 @@ final class ERDiagramViewModel { /// It belongs to the model rather than the view because an editor-tab switch destroys /// `ERDiagramView` and rebuilds it against the same model: a viewport held as view state came /// back at 100% scrolled to the origin every time the user left the tab and returned. - @ObservationIgnored let viewport = DiagramViewportController() + let viewport = DiagramViewportController() /// Selection outlives the view for the same reason. - var selectedNodeId: UUID? + @Published var selectedNodeId: UUID? // MARK: - Drag State - private(set) var isDragging = false - private(set) var draggingNodeId: UUID? - @ObservationIgnored private var dragNodeStart: CGPoint? - @ObservationIgnored private var lastDragTranslation: CGSize = .zero + @Published private(set) var isDragging = false + @Published private(set) var draggingNodeId: UUID? + private var dragNodeStart: CGPoint? + private var lastDragTranslation: CGSize = .zero // MARK: - Auto-Pan - @ObservationIgnored nonisolated(unsafe) private var autoPanTask: Task? - @ObservationIgnored private var autoPanVelocity: CGPoint = .zero - @ObservationIgnored private var autoPanAccum: CGPoint = .zero + nonisolated(unsafe) private var autoPanTask: Task? + private var autoPanVelocity: CGPoint = .zero + private var autoPanAccum: CGPoint = .zero private static let edgeThreshold: CGFloat = 40 private static let maxPanSpeed: CGFloat = 8 // MARK: - Positions - private(set) var computedLayout: [UUID: CGPoint] = [:] - private(set) var positionOverrides: [UUID: CGPoint] = [:] - @ObservationIgnored nonisolated(unsafe) private var layoutTask: Task? - private(set) var cachedNodeRects: [UUID: CGRect] = [:] - @ObservationIgnored private var columnCountByNodeId: [UUID: Int] = [:] - @ObservationIgnored private var nodeIdToName: [UUID: String] = [:] + @Published private(set) var computedLayout: [UUID: CGPoint] = [:] + @Published private(set) var positionOverrides: [UUID: CGPoint] = [:] + nonisolated(unsafe) private var layoutTask: Task? + @Published private(set) var cachedNodeRects: [UUID: CGRect] = [:] + private var columnCountByNodeId: [UUID: Int] = [:] + private var nodeIdToName: [UUID: String] = [:] - @ObservationIgnored private let services: AppServices + private let services: AppServices // MARK: - Initialization @@ -364,7 +363,7 @@ final class ERDiagramViewModel { // MARK: - Canvas Size - private(set) var cachedCanvasSize = CGSize(width: 800, height: 600) + @Published private(set) var cachedCanvasSize = CGSize(width: 800, height: 600) private static let canvasPadding: CGFloat = 80 // MARK: - Node Rect (for edge rendering) diff --git a/TablePro/ViewModels/FavoritesExpansionState.swift b/TablePro/ViewModels/FavoritesExpansionState.swift index 99aecb4f49..784a14b29b 100644 --- a/TablePro/ViewModels/FavoritesExpansionState.swift +++ b/TablePro/ViewModels/FavoritesExpansionState.swift @@ -3,24 +3,23 @@ // TablePro // +import Combine import Foundation -import Observation @MainActor -@Observable -internal final class FavoritesExpansionState { +internal final class FavoritesExpansionState: ObservableObject { static let shared = FavoritesExpansionState() - private(set) var foldersByConnection: [UUID: Set] = [:] - private(set) var linkedNodesByConnection: [UUID: Set] = [:] - private(set) var collapsedDatabaseEnvironmentsByConnection: [UUID: Set] = [:] + @Published private(set) var foldersByConnection: [UUID: Set] = [:] + @Published private(set) var linkedNodesByConnection: [UUID: Set] = [:] + @Published private(set) var collapsedDatabaseEnvironmentsByConnection: [UUID: Set] = [:] - @ObservationIgnored private let foldersKey = "com.TablePro.favoritesExpandedFolders" - @ObservationIgnored private let linkedKey = "com.TablePro.favoritesExpandedLinkedNodes" - @ObservationIgnored private let collapsedDatabaseEnvironmentsKey = + private let foldersKey = "com.TablePro.favoritesExpandedFolders" + private let linkedKey = "com.TablePro.favoritesExpandedLinkedNodes" + private let collapsedDatabaseEnvironmentsKey = "com.TablePro.favoritesCollapsedDatabaseEnvironments" - @ObservationIgnored private let defaults: UserDefaults + private let defaults: UserDefaults internal init(defaults: UserDefaults = AppStorageEnvironment.shared.defaults) { self.defaults = defaults diff --git a/TablePro/ViewModels/FavoritesSidebarViewModel.swift b/TablePro/ViewModels/FavoritesSidebarViewModel.swift index b589d03ef6..8898225db1 100644 --- a/TablePro/ViewModels/FavoritesSidebarViewModel.swift +++ b/TablePro/ViewModels/FavoritesSidebarViewModel.swift @@ -3,8 +3,8 @@ // TablePro // +import Combine import Foundation -import Observation internal struct FavoriteEditItem: Identifiable { let id = UUID() @@ -153,17 +153,17 @@ internal extension [FavoriteNode] { } } -@MainActor @Observable -internal final class FavoritesSidebarViewModel { - var editDialogItem: FavoriteEditItem? - var renamingFolderId: UUID? - var showDeleteConfirmation = false - var favoritesToDelete: [SQLFavorite] = [] - - @ObservationIgnored internal let connectionId: UUID - @ObservationIgnored private let cache: ConnectionDataCache - @ObservationIgnored private let services: AppServices - @ObservationIgnored private var manager: SQLFavoriteManager { services.sqlFavoriteManager } +@MainActor +internal final class FavoritesSidebarViewModel: ObservableObject { + @Published var editDialogItem: FavoriteEditItem? + @Published var renamingFolderId: UUID? + @Published var showDeleteConfirmation = false + @Published var favoritesToDelete: [SQLFavorite] = [] + + internal let connectionId: UUID + private let cache: ConnectionDataCache + private let services: AppServices + private var manager: SQLFavoriteManager { services.sqlFavoriteManager } var isInitialLoadComplete: Bool { cache.isInitialLoadComplete } diff --git a/TablePro/ViewModels/HistoryPanelViewModel.swift b/TablePro/ViewModels/HistoryPanelViewModel.swift index bb7e0f2797..e77fa7263c 100644 --- a/TablePro/ViewModels/HistoryPanelViewModel.swift +++ b/TablePro/ViewModels/HistoryPanelViewModel.swift @@ -1,24 +1,22 @@ import Combine import Foundation -import Observation @MainActor -@Observable -final class HistoryPanelViewModel { +final class HistoryPanelViewModel: ObservableObject { static let pageSize = 60 static let maximumRefreshWindow = 600 - private(set) var sections: [QueryHistoryDaySection] = [] - private(set) var isLoading = false - private(set) var isLoadingMore = false - private(set) var hasLoadedOnce = false - private(set) var hasMore = false - private(set) var totalLoaded = 0 + @Published private(set) var sections: [QueryHistoryDaySection] = [] + @Published private(set) var isLoading = false + @Published private(set) var isLoadingMore = false + @Published private(set) var hasLoadedOnce = false + @Published private(set) var hasMore = false + @Published private(set) var totalLoaded = 0 /// An unreadable store returns the same empty page as a store with nothing in it, so without /// this the drawer stated positively that no query had ever been recorded. - private(set) var isStoreUnavailable = false + @Published private(set) var isStoreUnavailable = false - var selectedEntryId: UUID? + @Published var selectedEntryId: UUID? let state: HistoryPanelState @@ -27,11 +25,11 @@ final class HistoryPanelViewModel { private let connectionDirectory: HistoryConnectionDirectory private let pageSize: Int - private var entries: [QueryHistoryEntry] = [] - private var nextCursor: QueryHistoryCursor? - private var loadedPageCount = 1 + @Published private var entries: [QueryHistoryEntry] = [] + @Published private var nextCursor: QueryHistoryCursor? + @Published private var loadedPageCount = 1 private var loadToken = UUID() - private var searchDebounce: Task? + @Published private var searchDebounce: Task? private var liveRefresh: Task? private var updateSubscription: AnyCancellable? diff --git a/TablePro/ViewModels/JSONRowInspectorViewModel.swift b/TablePro/ViewModels/JSONRowInspectorViewModel.swift index 641f4af37d..7579c25dbb 100644 --- a/TablePro/ViewModels/JSONRowInspectorViewModel.swift +++ b/TablePro/ViewModels/JSONRowInspectorViewModel.swift @@ -6,6 +6,7 @@ // import AppKit +import Combine import Foundation import os @@ -19,26 +20,25 @@ typealias JSONForeignKeyRowFetch = @MainActor ( ) async throws -> ForeignKeyRowFetcher.FetchedRow? @MainActor -@Observable -final class JSONRowInspectorViewModel { - private(set) var root: JSONRowNode? - private(set) var states = JSONForeignKeyStates() +final class JSONRowInspectorViewModel: ObservableObject { + @Published private(set) var root: JSONRowNode? + @Published private(set) var states = JSONForeignKeyStates() /// Session state, not a setting. Following a key costs a query per key, so the tab opens with /// them closed however the reader left it last time, and turning it on is a deliberate act. - private(set) var alwaysExpandForeignKeys = false + @Published private(set) var alwaysExpandForeignKeys = false - var filterText: String = "" + @Published var filterText: String = "" - private var expanded: Set = [] - private var chains: [JSONNodePath: [JSONForeignKeyVisit]] = [:] - private var fetches: [JSONNodePath: Task] = [:] - private var lastSnapshot: JSONRowSnapshot? + @Published private var expanded: Set = [] + @Published private var chains: [JSONNodePath: [JSONForeignKeyVisit]] = [:] + @Published private var fetches: [JSONNodePath: Task] = [:] + @Published private var lastSnapshot: JSONRowSnapshot? /// Bumped by every rebuild and every reset. A fetch that returns after one discards itself, /// because `Task.cancel()` cannot interrupt a query already in flight. - private var generation = 0 - private var scope: DatabaseScope? - private var databaseType: DatabaseType? - @ObservationIgnored private let fetchRow: JSONForeignKeyRowFetch + @Published private var generation = 0 + @Published private var scope: DatabaseScope? + @Published private var databaseType: DatabaseType? + private let fetchRow: JSONForeignKeyRowFetch private static let logger = Logger(subsystem: "com.TablePro", category: "JSONRowInspector") diff --git a/TablePro/ViewModels/QueryInsightsViewModel.swift b/TablePro/ViewModels/QueryInsightsViewModel.swift index ac21f15ef1..8e652b8dd5 100644 --- a/TablePro/ViewModels/QueryInsightsViewModel.swift +++ b/TablePro/ViewModels/QueryInsightsViewModel.swift @@ -1,29 +1,27 @@ import Combine import Foundation -import Observation import os @MainActor -@Observable -final class QueryInsightsViewModel { +final class QueryInsightsViewModel: ObservableObject { nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "QueryInsights") /// Recording a grid full of edits broadcasts once per statement, and every panel on this screen /// is a full aggregate. Collapsing the burst keeps one user action to one recomputation. private static let refreshDebounce = Duration.milliseconds(400) - private(set) var snapshot: QueryInsightsSnapshot = .empty - private(set) var hasLoadedContent = false - private(set) var isRefreshing = false - private(set) var isStoreUnavailable = false - private(set) var lastRefreshDate: Date? + @Published private(set) var snapshot: QueryInsightsSnapshot = .empty + @Published private(set) var hasLoadedContent = false + @Published private(set) var isRefreshing = false + @Published private(set) var isStoreUnavailable = false + @Published private(set) var lastRefreshDate: Date? let connectionId: UUID - var showsAllConnections: Bool { didSet { persistAndReload(oldValue != showsAllConnections) } } - var sources: Set { didSet { persistAndReload(oldValue != sources) } } - var dateRange: HistoryDateRange { didSet { persistAndReload(oldValue != dateRange) } } - var slowestRanking: QueryInsightsSlowestRanking { didSet { persistAndReload(oldValue != slowestRanking) } } + @Published var showsAllConnections: Bool { didSet { persistAndReload(oldValue != showsAllConnections) } } + @Published var sources: Set { didSet { persistAndReload(oldValue != sources) } } + @Published var dateRange: HistoryDateRange { didSet { persistAndReload(oldValue != dateRange) } } + @Published var slowestRanking: QueryInsightsSlowestRanking { didSet { persistAndReload(oldValue != slowestRanking) } } private let history: QueryHistoryReading private var isApplyingBulkChange = false diff --git a/TablePro/ViewModels/QueryPlanComparisonModel.swift b/TablePro/ViewModels/QueryPlanComparisonModel.swift index db8ad6ee03..45a3bbdbdc 100644 --- a/TablePro/ViewModels/QueryPlanComparisonModel.swift +++ b/TablePro/ViewModels/QueryPlanComparisonModel.swift @@ -8,7 +8,6 @@ import Combine import Foundation -import Observation import TableProPluginKit /// What the pane can show for the selected baseline. @@ -55,8 +54,7 @@ enum QueryPlanComparisonEmptyReason: Hashable, Sendable { } @MainActor -@Observable -final class QueryPlanComparisonModel { +final class QueryPlanComparisonModel: ObservableObject { enum State: Hashable, Sendable { case loading case empty(QueryPlanComparisonEmptyReason) @@ -72,19 +70,19 @@ final class QueryPlanComparisonModel { /// Enough runs to find the one before yesterday's deploy, few enough to stay a menu. nonisolated static let baselineListLimit = 50 - private(set) var baselines: [QueryPlanSnapshotSummary] = [] - private(set) var state: State = .loading + @Published private(set) var baselines: [QueryPlanSnapshotSummary] = [] + @Published private(set) var state: State = .loading - var selectedBaselineId: UUID? { + @Published var selectedBaselineId: UUID? { didSet { guard oldValue != selectedBaselineId else { return } reloadComparison() } } - private var context: QueryPlanContext? - private var currentPlan: QueryPlan? - private var currentRawText = "" + @Published private var context: QueryPlanContext? + @Published private var currentPlan: QueryPlan? + @Published private var currentRawText = "" private let history: QueryPlanSnapshotReading private let isCapturePaused: @MainActor () -> Bool private var updateSubscription: AnyCancellable? diff --git a/TablePro/ViewModels/QueryPlanViewState.swift b/TablePro/ViewModels/QueryPlanViewState.swift index ac52f4716d..4ad33cb9d2 100644 --- a/TablePro/ViewModels/QueryPlanViewState.swift +++ b/TablePro/ViewModels/QueryPlanViewState.swift @@ -7,26 +7,24 @@ // and the selected step, zoom and scroll per plan. // +import Combine import Foundation -import Observation /// Belongs to the editor tab rather than to one plan in it, so running the statement again replaces /// the plan and keeps the pane in Compare with the baseline that was chosen. @MainActor -@Observable -final class QueryPlanTabState { - var viewMode: QueryPlanViewMode = .diagram +final class QueryPlanTabState: ObservableObject { + @Published var viewMode: QueryPlanViewMode = .diagram - @ObservationIgnored let comparison = QueryPlanComparisonModel() + let comparison = QueryPlanComparisonModel() } @MainActor -@Observable -final class QueryPlanViewState { +final class QueryPlanViewState: ObservableObject { /// Shared by the diagram and the outline, so switching view mode keeps the selected step. - var selectedNodeId: UUID? + @Published var selectedNodeId: UUID? - @ObservationIgnored let viewport = DiagramViewportController() + let viewport = DiagramViewportController() } /// A re-run replaces a plan's result set with a new one, which drops the old plan's state the next diff --git a/TablePro/ViewModels/QuickSwitcherViewModel.swift b/TablePro/ViewModels/QuickSwitcherViewModel.swift index ddce4a0bd9..98ade5ce49 100644 --- a/TablePro/ViewModels/QuickSwitcherViewModel.swift +++ b/TablePro/ViewModels/QuickSwitcherViewModel.swift @@ -3,8 +3,8 @@ // TablePro // +import Combine import Foundation -import Observation import os import TableProPluginKit @@ -17,8 +17,7 @@ private enum QuickSwitcherRanking { } @MainActor -@Observable -internal final class QuickSwitcherViewModel { +internal final class QuickSwitcherViewModel: ObservableObject { struct CrossConnectionCatalogVersion: Hashable { struct Entry: Hashable { let connectionId: UUID @@ -51,55 +50,55 @@ internal final class QuickSwitcherViewModel { private static let recentLimit = 10 private static let filterDebounceNanoseconds: UInt64 = 40_000_000 - @ObservationIgnored private let services: AppServices - @ObservationIgnored private let connectionId: UUID - @ObservationIgnored private let defaults: UserDefaults - @ObservationIgnored private let frecencyStore: QuickSwitcherFrecencyStore - @ObservationIgnored private let catalogStore: QuickSwitcherCatalogStore + private let services: AppServices + private let connectionId: UUID + private let defaults: UserDefaults + private let frecencyStore: QuickSwitcherFrecencyStore + private let catalogStore: QuickSwitcherCatalogStore /// The catalog arriving is what ends the load, so this owns `isLoading` rather than the one /// call site that happened to fetch it. A load that is superseded or cancelled after it has /// already delivered its items cannot then strand the panel on a spinner. - @ObservationIgnored internal var allItems: [QuickSwitcherItem] = [] { + internal var allItems: [QuickSwitcherItem] = [] { didSet { isLoading = false scheduleFilter(debounced: false) } } - @ObservationIgnored internal var crossConnectionItems: [QuickSwitcherItem] = [] { + internal var crossConnectionItems: [QuickSwitcherItem] = [] { didSet { scheduleFilter(debounced: false) } } - @ObservationIgnored internal var crossConnectionQueryItems: [QuickSwitcherItem] = [] { + internal var crossConnectionQueryItems: [QuickSwitcherItem] = [] { didSet { scheduleFilter(debounced: false) } } - @ObservationIgnored private var filterTask: Task? - @ObservationIgnored private var selectionQuery: String? - @ObservationIgnored private var selectionScope: QuickSwitcherScope? - @ObservationIgnored private var activeLoadId = UUID() - @ObservationIgnored private var activeCrossConnectionLoadId = UUID() - @ObservationIgnored private var activeCrossConnectionQueryLoadId = UUID() - @ObservationIgnored private var loadedCrossConnectionVersion: CrossConnectionCatalogVersion? - @ObservationIgnored private var loadedCrossConnectionQueryVersion: CrossConnectionQueryVersion? - - private(set) var groups: [Group] = [] - private(set) var isLoading = true + private var filterTask: Task? + private var selectionQuery: String? + private var selectionScope: QuickSwitcherScope? + private var activeLoadId = UUID() + private var activeCrossConnectionLoadId = UUID() + private var activeCrossConnectionQueryLoadId = UUID() + private var loadedCrossConnectionVersion: CrossConnectionCatalogVersion? + private var loadedCrossConnectionQueryVersion: CrossConnectionQueryVersion? + + @Published private(set) var groups: [Group] = [] + @Published private(set) var isLoading = true /// Ranking the scoped catalog runs off the main actor behind a debounce, so `groups` is empty /// for a beat after the catalog arrives. Without this the panel calls that emptiness "no /// results" and says so, for the whole first sort. - private(set) var isFiltering = false - private(set) var isLoadingCrossConnections = false - private(set) var isLoadingCrossConnectionQueries = false - private(set) var crossConnectionQueryContentRevision = 0 - var selectedItemId: String? + @Published private(set) var isFiltering = false + @Published private(set) var isLoadingCrossConnections = false + @Published private(set) var isLoadingCrossConnectionQueries = false + @Published private(set) var crossConnectionQueryContentRevision = 0 + @Published var selectedItemId: String? - var searchText = "" { + @Published var searchText = "" { didSet { guard oldValue != searchText else { return } scheduleFilter(debounced: true) } } - var scope: QuickSwitcherScope = .all { + @Published var scope: QuickSwitcherScope = .all { didSet { guard oldValue != scope else { return } scheduleFilter(debounced: false) @@ -119,7 +118,7 @@ internal final class QuickSwitcherViewModel { /// rendering as "No results". /// /// `isFiltering` is the half that cannot be replaced by testing `allItems`: that property is - /// `@ObservationIgnored`, so nothing re-renders when it changes, and its `didSet` only + /// ``, so nothing re-renders when it changes, and its `didSet` only /// schedules the filter. `groups` is committed an await later, so between the catalog landing /// and the filter committing there is a frame with nothing to show and no load in flight. var isLoadingResults: Bool { diff --git a/TablePro/ViewModels/RedisKeyTreeViewModel.swift b/TablePro/ViewModels/RedisKeyTreeViewModel.swift index e0af9c2b6a..8134e71d9c 100644 --- a/TablePro/ViewModels/RedisKeyTreeViewModel.swift +++ b/TablePro/ViewModels/RedisKeyTreeViewModel.swift @@ -3,22 +3,22 @@ // TablePro // +import Combine import Foundation -import Observation import os import TableProPluginKit -@MainActor @Observable -internal final class RedisKeyTreeViewModel { +@MainActor +internal final class RedisKeyTreeViewModel: ObservableObject { nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "RedisKeyTree") internal static let maxKeys = 50_000 - var rootNodes: [RedisKeyNode] = [] - var isLoading = false - var isTruncated = false - var separator: String = ":" + @Published var rootNodes: [RedisKeyNode] = [] + @Published var isLoading = false + @Published var isTruncated = false + @Published var separator: String = ":" - private(set) var allKeys: [(key: String, type: String)] = [] + @Published private(set) var allKeys: [(key: String, type: String)] = [] /// Test-only setter for allKeys var allKeysForTesting: [(key: String, type: String)] { diff --git a/TablePro/ViewModels/SchemaEditorViewModel.swift b/TablePro/ViewModels/SchemaEditorViewModel.swift index a5a56049af..20c231cc0e 100644 --- a/TablePro/ViewModels/SchemaEditorViewModel.swift +++ b/TablePro/ViewModels/SchemaEditorViewModel.swift @@ -3,6 +3,7 @@ // TablePro // +import Combine import Foundation import os import TableProPluginKit @@ -14,8 +15,8 @@ import TableProPluginKit /// is the text the gate authorizes and the server runs. The current state is read immediately /// before the plan is built, which is what keeps the privilege diff from revoking a grant another /// session added while the sheet was open. -@MainActor @Observable -final class SchemaEditorViewModel { +@MainActor +final class SchemaEditorViewModel: ObservableObject { nonisolated static let logger = Logger(subsystem: "com.TablePro", category: "SchemaEditor") enum Mode: Equatable { @@ -34,24 +35,24 @@ final class SchemaEditorViewModel { case failed(String) } - private(set) var mode: Mode + @Published private(set) var mode: Mode let connectionId: UUID let databaseType: DatabaseType let database: String? - private(set) var loadState: LoadState = .loading - private(set) var current: PluginSchemaDetails? - private(set) var ownerCandidates: [String] = [] - private(set) var privileges: [PluginPrivilegeDescriptor] = [] - private(set) var existingSchemas: [String] = [] - private(set) var isApplying = false - private(set) var failure: String? + @Published private(set) var loadState: LoadState = .loading + @Published private(set) var current: PluginSchemaDetails? + @Published private(set) var ownerCandidates: [String] = [] + @Published private(set) var privileges: [PluginPrivilegeDescriptor] = [] + @Published private(set) var existingSchemas: [String] = [] + @Published private(set) var isApplying = false + @Published private(set) var failure: String? - var name = "" - var owner = "" - var comment = "" + @Published var name = "" + @Published var owner = "" + @Published var comment = "" - private(set) var granteeRows: [SchemaGranteeRow] = [] + @Published private(set) var granteeRows: [SchemaGranteeRow] = [] /// What the form held when it loaded. Dirtiness is this compared with the form now, never a /// latch: a checkbox toggled on and straight off again is not an edit, and treating it as one diff --git a/TablePro/ViewModels/ServerDashboardViewModel.swift b/TablePro/ViewModels/ServerDashboardViewModel.swift index 18308c80d3..23a40c074a 100644 --- a/TablePro/ViewModels/ServerDashboardViewModel.swift +++ b/TablePro/ViewModels/ServerDashboardViewModel.swift @@ -1,26 +1,26 @@ +import Combine import Foundation import os @MainActor -@Observable -final class ServerDashboardViewModel { +final class ServerDashboardViewModel: ObservableObject { nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "ServerDashboard") // MARK: - Configuration let connectionId: UUID let databaseType: DatabaseType - private(set) var provider: ServerDashboardQueryProvider? + @Published private(set) var provider: ServerDashboardQueryProvider? // MARK: - Data - var sessions: [DashboardSession] = [] - var metrics: [DashboardMetric] = [] - var slowQueries: [DashboardSlowQuery] = [] + @Published var sessions: [DashboardSession] = [] + @Published var metrics: [DashboardMetric] = [] + @Published var slowQueries: [DashboardSlowQuery] = [] // MARK: - Refresh State - var refreshInterval: DashboardRefreshInterval = .fiveSeconds { + @Published var refreshInterval: DashboardRefreshInterval = .fiveSeconds { didSet { guard oldValue != refreshInterval else { return } if refreshTask != nil || refreshInterval != .off { @@ -29,31 +29,31 @@ final class ServerDashboardViewModel { } } - var isPaused: Bool = false - var isRefreshing: Bool = false - var lastRefreshDate: Date? - var panelErrors: [DashboardPanel: String] = [:] + @Published var isPaused: Bool = false + @Published var isRefreshing: Bool = false + @Published var lastRefreshDate: Date? + @Published var panelErrors: [DashboardPanel: String] = [:] // MARK: - Sort State - var sessionSortOrder: [KeyPathComparator] = [ + @Published var sessionSortOrder: [KeyPathComparator] = [ KeyPathComparator(\DashboardSession.durationSeconds, order: .reverse), ] // MARK: - Kill / Cancel Confirmation - var showKillConfirmation: Bool = false - var pendingKillProcessId: String? - var showCancelConfirmation: Bool = false - var pendingCancelProcessId: String? - var actionError: String? + @Published var showKillConfirmation: Bool = false + @Published var pendingKillProcessId: String? + @Published var showCancelConfirmation: Bool = false + @Published var pendingCancelProcessId: String? + @Published var actionError: String? // MARK: - Private - @ObservationIgnored nonisolated(unsafe) private var refreshTask: Task? - @ObservationIgnored private let services: AppServices - @ObservationIgnored private var providerServerVersion: String? - @ObservationIgnored private var hasAdoptedServerVersion = false + nonisolated(unsafe) private var refreshTask: Task? + private let services: AppServices + private var providerServerVersion: String? + private var hasAdoptedServerVersion = false // MARK: - Computed Properties diff --git a/TablePro/ViewModels/SidebarViewModel.swift b/TablePro/ViewModels/SidebarViewModel.swift index 8938a949ff..ddccc87586 100644 --- a/TablePro/ViewModels/SidebarViewModel.swift +++ b/TablePro/ViewModels/SidebarViewModel.swift @@ -3,12 +3,13 @@ // TablePro // -import Observation +import Combine import os import SwiftUI -@MainActor @Observable -final class SidebarViewModel { +@MainActor +final class SidebarViewModel: ObservableObject { + private var searchTextObservation: AnyCancellable? private static let logger = Logger(subsystem: "com.TablePro", category: "SidebarViewModel") private static var registry: [UUID: SidebarViewModel] = [:] private static let searchDebounceNanoseconds: UInt64 = 150_000_000 @@ -95,27 +96,22 @@ final class SidebarViewModel { /// meant a keystroke reached the filter only while a SwiftUI body was evaluating, and the view /// that carried it also re-seeded the debounce on every rebuild. private func observeSearchText() { - withObservationTracking { [weak self] in - _ = self?.sharedState.searchText - } onChange: { [weak self] in - Task { @MainActor in - guard let self else { return } - self.scheduleFilterQueryUpdate(oldValue: self.filterQuery) - self.observeSearchText() - } + searchTextObservation = sharedState.onMainActorChange { [weak self] in + guard let self else { return } + self.scheduleFilterQueryUpdate(oldValue: self.filterQuery) } } - private(set) var filterQuery = "" { + @Published private(set) var filterQuery = "" { didSet { invalidateFilterCaches() } } - @ObservationIgnored private var filterDebounceTask: Task? + private var filterDebounceTask: Task? - var expanded: ExpansionState { + @Published var expanded: ExpansionState { didSet { persistExpansion(oldValue: oldValue) } } - var isRedisKeysExpanded: Bool { + @Published var isRedisKeysExpanded: Bool { didSet { AppStorageEnvironment.shared.defaults.set( isRedisKeysExpanded, @@ -123,7 +119,7 @@ final class SidebarViewModel { ) } } - var isRecentsExpanded: Bool { + @Published var isRecentsExpanded: Bool { didSet { AppStorageEnvironment.shared.defaults.set( isRecentsExpanded, @@ -135,16 +131,16 @@ final class SidebarViewModel { get { sharedState.redisKeyTreeViewModel } set { sharedState.redisKeyTreeViewModel = newValue } } - var showOperationDialog = false - var pendingOperationType: TableOperationType? - var pendingOperationTables: [DatabaseTreeTableRef] = [] + @Published var showOperationDialog = false + @Published var pendingOperationType: TableOperationType? + @Published var pendingOperationTables: [DatabaseTreeTableRef] = [] // MARK: - Binding Storage - private var selectedTablesBinding: Binding> - private var pendingTruncatesBinding: Binding> - private var pendingDeletesBinding: Binding> - private var tableOperationOptionsBinding: Binding<[DatabaseTreeTableRef: TableOperationOptions]> + @Published private var selectedTablesBinding: Binding> + @Published private var pendingTruncatesBinding: Binding> + @Published private var pendingDeletesBinding: Binding> + @Published private var tableOperationOptionsBinding: Binding<[DatabaseTreeTableRef: TableOperationOptions]> let databaseType: DatabaseType // MARK: - Dependencies @@ -153,7 +149,7 @@ final class SidebarViewModel { /// The single connection-scoped state holder. Search text and the Redis key /// tree live here so this view model and the sidebar views share one source. - @ObservationIgnored let sharedState: SharedSidebarState + let sharedState: SharedSidebarState // MARK: - Convenience Accessors @@ -386,18 +382,18 @@ final class SidebarViewModel { // MARK: - Filtering - @ObservationIgnored private var cachedKindBuckets: [SidebarObjectKind: [TableInfo]] = [:] - @ObservationIgnored private var cachedKindFingerprint: (count: Int, generation: Int)? + private var cachedKindBuckets: [SidebarObjectKind: [TableInfo]] = [:] + private var cachedKindFingerprint: (count: Int, generation: Int)? - @ObservationIgnored private var cachedFilteredByKind: [SidebarObjectKind: [TableInfo]] = [:] - @ObservationIgnored private var cachedFilteredByKindFingerprint: (count: Int, generation: Int, query: String)? + private var cachedFilteredByKind: [SidebarObjectKind: [TableInfo]] = [:] + private var cachedFilteredByKindFingerprint: (count: Int, generation: Int, query: String)? - @ObservationIgnored private var cachedFilteredRoutines: [SidebarObjectKind: [RoutineInfo]] = [:] - @ObservationIgnored private var cachedFilteredRoutinesFingerprint: (count: Int, generation: Int, query: String)? - @ObservationIgnored private var cachedFilteredTriggers: [TriggerInfo] = [] - @ObservationIgnored private var cachedFilteredTriggersFingerprint: (count: Int, generation: Int, query: String)? - @ObservationIgnored private var cachedFilteredUserTypes: [UserDefinedTypeInfo] = [] - @ObservationIgnored private var cachedFilteredUserTypesFingerprint: (count: Int, generation: Int, query: String)? + private var cachedFilteredRoutines: [SidebarObjectKind: [RoutineInfo]] = [:] + private var cachedFilteredRoutinesFingerprint: (count: Int, generation: Int, query: String)? + private var cachedFilteredTriggers: [TriggerInfo] = [] + private var cachedFilteredTriggersFingerprint: (count: Int, generation: Int, query: String)? + private var cachedFilteredUserTypes: [UserDefinedTypeInfo] = [] + private var cachedFilteredUserTypesFingerprint: (count: Int, generation: Int, query: String)? private var schemaGeneration: Int { SchemaService.shared.generationToken(for: connectionId) diff --git a/TablePro/ViewModels/UsersRolesViewModel.swift b/TablePro/ViewModels/UsersRolesViewModel.swift index b324729df1..9828f01815 100644 --- a/TablePro/ViewModels/UsersRolesViewModel.swift +++ b/TablePro/ViewModels/UsersRolesViewModel.swift @@ -1,11 +1,10 @@ +import Combine import Foundation -import Observation import os import TableProPluginKit @MainActor -@Observable -final class UsersRolesViewModel { +final class UsersRolesViewModel: ObservableObject { enum DetailSegment: String, CaseIterable, Identifiable { case privileges case attributes @@ -72,36 +71,33 @@ final class UsersRolesViewModel { let changeManager = PrincipalChangeManager() let privilegeTree = PrivilegeTreeModel() - private(set) var capabilities = Capabilities() - private(set) var databases: [String] = [] - private(set) var connectedPrincipal: PluginPrincipalRef? - private(set) var loadError: String? - private(set) var grantsError: String? - - var isLoading = false - var isResolvingDrop = false - var previewStatements: [SchemaStatement] = [] - var applyFailure: String? - - var selection: PluginPrincipalRef? - var selectedRefs: Set = [] - var selectedScopes: Set = [] - var detailSegment: DetailSegment = .privileges - var scopeMode: ScopeMode = .all - var principalFilter = "" - var scopeFilter = "" - var privilegeFilter = "" - var activeSheet: ActiveSheet? - var actionError: String? - - @ObservationIgnored - private(set) var loader: PrincipalListLoader? - - @ObservationIgnored + @Published private(set) var capabilities = Capabilities() + @Published private(set) var databases: [String] = [] + @Published private(set) var connectedPrincipal: PluginPrincipalRef? + @Published private(set) var loadError: String? + @Published private(set) var grantsError: String? + + @Published var isLoading = false + @Published var isResolvingDrop = false + @Published var previewStatements: [SchemaStatement] = [] + @Published var applyFailure: String? + + @Published var selection: PluginPrincipalRef? + @Published var selectedRefs: Set = [] + @Published var selectedScopes: Set = [] + @Published var detailSegment: DetailSegment = .privileges + @Published var scopeMode: ScopeMode = .all + @Published var principalFilter = "" + @Published var scopeFilter = "" + @Published var privilegeFilter = "" + @Published var activeSheet: ActiveSheet? + @Published var actionError: String? + + @Published private(set) var loader: PrincipalListLoader? + let expansionStore: PrivilegeExpansionStore - @ObservationIgnored - var scopeSearchTask: Task? + @Published var scopeSearchTask: Task? init(connectionId: UUID, databaseType: DatabaseType) { self.connectionId = connectionId diff --git a/TablePro/ViewModels/WelcomeViewModel.swift b/TablePro/ViewModels/WelcomeViewModel.swift index f12a85f7f1..b9750dbc16 100644 --- a/TablePro/ViewModels/WelcomeViewModel.swift +++ b/TablePro/ViewModels/WelcomeViewModel.swift @@ -55,99 +55,99 @@ internal protocol WelcomeOutlineControlling: AnyObject { func focusList(selectFirstRow: Bool) } -@MainActor @Observable -final class WelcomeViewModel { +@MainActor +final class WelcomeViewModel: ObservableObject { nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "WelcomeViewModel") private static let teamLibraryNamespace = UUID(uuidString: "00000000-0000-0000-0000-000000000000") ?? UUID() private static let searchDebounceNanoseconds: UInt64 = 150_000_000 - @ObservationIgnored let services: AppServices - @ObservationIgnored let recentConnections: RecentConnectionsStore - @ObservationIgnored let listPreferences: ConnectionListPreferences - @ObservationIgnored weak var outlineController: WelcomeOutlineControlling? + let services: AppServices + let recentConnections: RecentConnectionsStore + let listPreferences: ConnectionListPreferences + weak var outlineController: WelcomeOutlineControlling? var storage: ConnectionStorage { services.connectionStorage } var groupStorage: GroupStorage { services.groupStorage } // MARK: - Library - private(set) var connections: [DatabaseConnection] = [] - private(set) var groups: [ConnectionGroup] = [] - private(set) var tags: [ConnectionTag] = [] - @ObservationIgnored private(set) var connectionsById: [UUID: DatabaseConnection] = [:] - @ObservationIgnored private(set) var groupsById: [UUID: ConnectionGroup] = [:] - @ObservationIgnored private(set) var tagsById: [UUID: ConnectionTag] = [:] - @ObservationIgnored private(set) var groupGraph = LibraryGroupGraph(groups: [ConnectionGroup]()) - @ObservationIgnored private(set) var groupConnectionCounts: [UUID: Int] = [:] - var linkedConnections: [LinkedConnection] = [] { + @Published private(set) var connections: [DatabaseConnection] = [] + @Published private(set) var groups: [ConnectionGroup] = [] + @Published private(set) var tags: [ConnectionTag] = [] + private(set) var connectionsById: [UUID: DatabaseConnection] = [:] + private(set) var groupsById: [UUID: ConnectionGroup] = [:] + private(set) var tagsById: [UUID: ConnectionTag] = [:] + private(set) var groupGraph = LibraryGroupGraph(groups: [ConnectionGroup]()) + private(set) var groupConnectionCounts: [UUID: Int] = [:] + @Published var linkedConnections: [LinkedConnection] = [] { didSet { rebuildOutline() } } - var teamLibraryConnections: [LinkedConnection] = [] { + @Published var teamLibraryConnections: [LinkedConnection] = [] { didSet { rebuildOutline() } } // MARK: - Query - var searchText = "" { didSet { scheduleRebuild(previous: oldValue) } } - var searchTokens: [WelcomeTagToken] = [] { + @Published var searchText = "" { didSet { scheduleRebuild(previous: oldValue) } } + @Published var searchTokens: [WelcomeTagToken] = [] { didSet { if searchTokens != oldValue { rebuildOutline() } } } - var tagMatch: LibraryTagMatch = .any { + @Published var tagMatch: LibraryTagMatch = .any { didSet { if tagMatch != oldValue { rebuildOutline() } } } // MARK: - Outline - private(set) var outline: LibraryOutline = .empty - private(set) var outlineRevision = 0 - private(set) var sortMode: LibrarySortMode - var expandedGroupIds: Set = [] { + @Published private(set) var outline: LibraryOutline = .empty + @Published private(set) var outlineRevision = 0 + @Published private(set) var sortMode: LibrarySortMode + @Published var expandedGroupIds: Set = [] { didSet { groupExpansionStore.save(expandedGroupIds) } } - var selection: [LibraryRowID] = [] + @Published var selection: [LibraryRowID] = [] // MARK: - Presentation - private(set) var hasImportableApp = false - var presentsWelcomeSheet = false - var connectionsToDelete: [DatabaseConnection] = [] - var showDeleteConfirmation = false - var pendingDeleteHasFavorites = false - @ObservationIgnored private var deleteRequestToken = UUID() - var showDeleteGroupConfirmation = false - var groupToDelete: ConnectionGroup? - var activeSheet: WelcomeActiveSheet? - var pluginInstallConnection: DatabaseConnection? - - var databaseTypeChooser: DatabaseTypeChooserPayload? - var urlImportPresented = false - var pendingInstallType: DatabaseType? - @ObservationIgnored var pendingInstallPayload: DatabaseTypeChooserPayload? - - var libraryErrorMessage: String? - - var connectionError: String? - var connectionErrorRecovery: PendingConnectionRecovery? - var showConnectionError = false - var pluginDiagnostic: PluginDiagnosticItem? - - var showImportFilePanel = false - var importResultCount: Int? + @Published private(set) var hasImportableApp = false + @Published var presentsWelcomeSheet = false + @Published var connectionsToDelete: [DatabaseConnection] = [] + @Published var showDeleteConfirmation = false + @Published var pendingDeleteHasFavorites = false + private var deleteRequestToken = UUID() + @Published var showDeleteGroupConfirmation = false + @Published var groupToDelete: ConnectionGroup? + @Published var activeSheet: WelcomeActiveSheet? + @Published var pluginInstallConnection: DatabaseConnection? + + @Published var databaseTypeChooser: DatabaseTypeChooserPayload? + @Published var urlImportPresented = false + @Published var pendingInstallType: DatabaseType? + var pendingInstallPayload: DatabaseTypeChooserPayload? + + @Published var libraryErrorMessage: String? + + @Published var connectionError: String? + @Published var connectionErrorRecovery: PendingConnectionRecovery? + @Published var showConnectionError = false + @Published var pluginDiagnostic: PluginDiagnosticItem? + + @Published var showImportFilePanel = false + @Published var importResultCount: Int? /// Set when a sheet (import file / import-from-app) finishes work and is about to dismiss. /// Flushed in the sheet's `onDismiss` so the result alert appears after the sheet animation. - var pendingImportResultCount: Int? + @Published var pendingImportResultCount: Int? // MARK: - Observers - @ObservationIgnored private var connectionUpdatedCancellable: AnyCancellable? - @ObservationIgnored private var listStateCancellable: AnyCancellable? - @ObservationIgnored private var linkedFoldersCancellable: AnyCancellable? - @ObservationIgnored private var teamLibraryCancellable: AnyCancellable? - @ObservationIgnored private var licenseCancellable: AnyCancellable? - @ObservationIgnored private var welcomeRouterTask: Task? - @ObservationIgnored private var searchDebounceTask: Task? - @ObservationIgnored private let importableAppDetector: @MainActor () -> Bool - @ObservationIgnored private let groupExpansionStore: WelcomeGroupExpansionStore - @ObservationIgnored private let hasStoredGroupExpansion: Bool + private var connectionUpdatedCancellable: AnyCancellable? + private var listStateCancellable: AnyCancellable? + private var linkedFoldersCancellable: AnyCancellable? + private var teamLibraryCancellable: AnyCancellable? + private var licenseCancellable: AnyCancellable? + private var welcomeRouterTask: Task? + private var searchDebounceTask: Task? + private let importableAppDetector: @MainActor () -> Bool + private let groupExpansionStore: WelcomeGroupExpansionStore + private let hasStoredGroupExpansion: Bool // MARK: - Initialization @@ -467,13 +467,9 @@ final class WelcomeViewModel { return await withTaskCancellationHandler { await withCheckedContinuation { continuation in box.set(continuation) - withObservationTracking({ - _ = router.pendingRequest - _ = router.pendingImport - _ = router.pendingConnectionShare - _ = router.pendingError - _ = router.pendingPluginInstall - }, onChange: { + /// One-shot: the continuation resumes on the first change, and the sink is + /// released with the box, so nothing needs re-arming. + box.hold(router.onMainActorChange { box.resume(with: true) }) } @@ -483,6 +479,15 @@ final class WelcomeViewModel { } private final class ContinuationBox: @unchecked Sendable { + private var observation: AnyCancellable? + + /// Keeps the subscription alive until the continuation resumes. + func hold(_ cancellable: AnyCancellable) { + lock.lock() + defer { lock.unlock() } + observation = cancellable + } + private var continuation: CheckedContinuation? private let lock = NSLock() @@ -494,6 +499,7 @@ final class WelcomeViewModel { func resume(with value: Bool) { lock.lock() + observation = nil let pending = continuation continuation = nil lock.unlock() diff --git a/TablePro/Views/AIChat/AIChatMessageView.swift b/TablePro/Views/AIChat/AIChatMessageView.swift index a7f038255a..fc3bd041df 100644 --- a/TablePro/Views/AIChat/AIChatMessageView.swift +++ b/TablePro/Views/AIChat/AIChatMessageView.swift @@ -11,7 +11,7 @@ import SwiftUI struct AIChatMessageView: View, Equatable { private static let userBubbleTintOpacity: Double = 0.08 - let message: ChatTurn + @ObservedObject var message: ChatTurn var onRetry: (() -> Void)? var onRegenerate: (() -> Void)? var onEdit: (() -> Void)? @@ -206,7 +206,7 @@ struct AIChatMessageView: View, Equatable { } private struct AIChatBlockView: View, Equatable { - @Bindable var block: ChatContentBlock + @ObservedObject var block: ChatContentBlock static func == (lhs: AIChatBlockView, rhs: AIChatBlockView) -> Bool { lhs.block === rhs.block diff --git a/TablePro/Views/AIChat/AIChatPanelView.swift b/TablePro/Views/AIChat/AIChatPanelView.swift index 5433fbb680..c126f2dcd1 100644 --- a/TablePro/Views/AIChat/AIChatPanelView.swift +++ b/TablePro/Views/AIChat/AIChatPanelView.swift @@ -15,11 +15,14 @@ struct AIChatPanelView: View { var currentQuery: String? var queryResults: String? - @Bindable var viewModel: AIChatViewModel - private let settingsManager = AppSettingsManager.shared + @ObservedObject var viewModel: AIChatViewModel + @ObservedObject private var settingsManager = AppSettingsManager.shared @State private var bottomVisibleMessageID: UUID? @State private var pinnedToBottom: Bool = true - @State private var mentionState = MentionPopoverState() + @State private var scrollToBottomRequest: UUID? + + private static let bottomAnchorID = "chat.bottom.anchor" + @StateObject private var mentionState = MentionPopoverState() private var hasConfiguredProvider: Bool { settingsManager.ai.hasActiveProvider @@ -46,7 +49,7 @@ struct AIChatPanelView: View { .onAppear { viewModel.connection = connection } - .onChange(of: connection.id) { + .onChange(of: connection.id) { _ in viewModel.connection = connection } .task(id: settingsManager.ai.providers.map(\.id)) { @@ -105,6 +108,8 @@ struct AIChatPanelView: View { && bottomVisibleMessageID != lastMessageID return ZStack(alignment: .bottom) { + ScrollViewReader { proxy in + GeometryReader { viewport in ScrollView { LazyVStack(spacing: 0) { ForEach(visibleMessages) { message in @@ -129,39 +134,59 @@ struct AIChatPanelView: View { .padding(.vertical, 4) .id(message.id) } + /// The bottom sentinel. `scrollPosition(id:anchor:)` reported which row sat at + /// the bottom edge, which is macOS 14; measuring the last row against the + /// viewport answers the only question that read was asked: is the reader at the + /// end, or have they scrolled up. + Color.clear + .frame(height: 1) + .id(Self.bottomAnchorID) + .background( + GeometryReader { row in + Color.clear.preference( + key: ChatAtBottomKey.self, + value: row.frame(in: .global).maxY + <= viewport.frame(in: .global).maxY + 24 + ) + } + ) } .frame(maxWidth: .infinity) .padding(.horizontal, 8) .padding(.vertical, 8) - .scrollTargetLayout() } - .defaultScrollAnchor(.bottom) .scrollIndicators(.hidden) - .scrollPosition(id: $bottomVisibleMessageID, anchor: .bottom) - .onChange(of: bottomVisibleMessageID) { _, newValue in - pinnedToBottom = newValue == nil || newValue == lastMessageID + .onPreferenceChange(ChatAtBottomKey.self) { atBottom in + pinnedToBottom = atBottom + bottomVisibleMessageID = atBottom ? lastMessageID : visibleMessages.dropLast().last?.id } - .onChange(of: visibleMessages.count) { + .onAppear { proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) } + .onChange(of: visibleMessages.count) { _ in if pinnedToBottom { - bottomVisibleMessageID = lastMessageID + proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) } } - .onChange(of: viewModel.activeConversationID) { + .onChange(of: viewModel.activeConversationID) { _ in pinnedToBottom = true - bottomVisibleMessageID = lastMessageID + proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) } - .onChange(of: viewModel.isStreaming) { _, newValue in + .onChange(of: viewModel.isStreaming) { newValue in if !newValue, pinnedToBottom { - bottomVisibleMessageID = lastMessageID + proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) } } - .environment(viewModel) + .onChange(of: scrollToBottomRequest) { _ in + proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) + } + .environmentObject(viewModel) + } + } if isUserScrolledUp { Button { pinnedToBottom = true withMotion(.easeOut(duration: 0.2)) { - bottomVisibleMessageID = lastMessageID + scrollToBottomRequest = UUID() } } label: { Image(systemName: "arrow.down.circle.fill") @@ -635,3 +660,13 @@ struct AIChatPanelView: View { && !message.plainText.isEmpty } } + +/// Whether the conversation's last row is inside the viewport. Replaces reading +/// `scrollPosition(id:anchor:)`, which is macOS 14. +private struct ChatAtBottomKey: PreferenceKey { + static let defaultValue = true + + static func reduce(value: inout Bool, nextValue: () -> Bool) { + value = nextValue() + } +} diff --git a/TablePro/Views/AIChat/AIChatReasoningBlockView.swift b/TablePro/Views/AIChat/AIChatReasoningBlockView.swift index 7635c261e9..dd6955f900 100644 --- a/TablePro/Views/AIChat/AIChatReasoningBlockView.swift +++ b/TablePro/Views/AIChat/AIChatReasoningBlockView.swift @@ -47,7 +47,7 @@ struct AIChatReasoningBlockView: View { .onAppear { displayedText = block.text ?? "" } - .onChange(of: block.text ?? "") { _, newValue in + .onChange(of: block.text ?? "") { newValue in scheduleUpdate(to: newValue) } .onDisappear { diff --git a/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift b/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift index 97dd34143f..8c1fc9b7ec 100644 --- a/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift +++ b/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift @@ -6,9 +6,9 @@ import SwiftUI struct AIChatWalkthroughBlockView: View { - @Bindable var block: ChatContentBlock + @ObservedObject var block: ChatContentBlock - @Environment(AIChatViewModel.self) private var viewModel + @EnvironmentObject private var viewModel: AIChatViewModel @Environment(\.commandActions) private var actions @State private var expandedStepIDs: Set = [] @@ -108,7 +108,7 @@ struct AIChatWalkthroughBlockView: View { .frame(maxHeight: 260) .background(Color(nsColor: .textBackgroundColor)) .clipShape(RoundedRectangle(cornerRadius: 6)) - .onChange(of: scrollTarget) { _, target in + .onChange(of: scrollTarget) { target in guard let target else { return } withMotion(.easeInOut(duration: 0.25)) { proxy.scrollTo(target, anchor: .center) } } @@ -253,7 +253,7 @@ struct AIChatWalkthroughBlockView: View { .frame(maxHeight: 220) .background(Color(nsColor: .textBackgroundColor)) .clipShape(RoundedRectangle(cornerRadius: 6)) - .onChange(of: scrollTarget) { _, target in + .onChange(of: scrollTarget) { target in guard let target else { return } withMotion(.easeInOut(duration: 0.25)) { proxy.scrollTo(target, anchor: .center) } } diff --git a/TablePro/Views/AIChat/ChatComposerView.swift b/TablePro/Views/AIChat/ChatComposerView.swift index 2fd94c75e1..ddac0ca380 100644 --- a/TablePro/Views/AIChat/ChatComposerView.swift +++ b/TablePro/Views/AIChat/ChatComposerView.swift @@ -11,7 +11,7 @@ struct ChatComposerView: View { let placeholder: String let minLines: Int let maxLines: Int - @Bindable var mentionState: MentionPopoverState + @ObservedObject var mentionState: MentionPopoverState let onTextChange: (String, Int) -> Void let onSubmit: () -> Void let onAttach: (ContextItem) -> Void diff --git a/TablePro/Views/AIChat/ChatImageThumbnailView.swift b/TablePro/Views/AIChat/ChatImageThumbnailView.swift index 5471419c94..a1ecb03e49 100644 --- a/TablePro/Views/AIChat/ChatImageThumbnailView.swift +++ b/TablePro/Views/AIChat/ChatImageThumbnailView.swift @@ -39,6 +39,6 @@ struct ChatImageThumbnailView: View { Image(systemName: "photo") .foregroundStyle(.secondary) .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(nsColor: .quaternarySystemFill)) + .background(Color(nsColor: .quaternaryFill)) } } diff --git a/TablePro/Views/AIChat/MentionPopoverState.swift b/TablePro/Views/AIChat/MentionPopoverState.swift index 87a9388179..e3e938cb55 100644 --- a/TablePro/Views/AIChat/MentionPopoverState.swift +++ b/TablePro/Views/AIChat/MentionPopoverState.swift @@ -3,16 +3,16 @@ // TablePro // +import Combine import Foundation -@Observable @MainActor -final class MentionPopoverState { - var isVisible = false - var candidates: [MentionCandidate] = [] - var selectedIndex = 0 - var query = "" - var anchorRange = NSRange(location: 0, length: 0) +final class MentionPopoverState: ObservableObject { + @Published var isVisible = false + @Published var candidates: [MentionCandidate] = [] + @Published var selectedIndex = 0 + @Published var query = "" + @Published var anchorRange = NSRange(location: 0, length: 0) func reset() { isVisible = false diff --git a/TablePro/Views/AIChat/MentionSuggestionListView.swift b/TablePro/Views/AIChat/MentionSuggestionListView.swift index 34a88bbab0..a927a4aa47 100644 --- a/TablePro/Views/AIChat/MentionSuggestionListView.swift +++ b/TablePro/Views/AIChat/MentionSuggestionListView.swift @@ -6,7 +6,7 @@ import SwiftUI struct MentionSuggestionListView: View { - @Bindable var state: MentionPopoverState + @ObservedObject var state: MentionPopoverState let onSelect: (Int) -> Void /// Hover is its own state rather than a write into `selectedIndex`. Driving the selection from @@ -79,6 +79,6 @@ private struct MentionRowView: View { private var rowBackground: Color { if isSelected { return Color(nsColor: .selectedContentBackgroundColor) } - return isHovered ? Color(nsColor: .quaternarySystemFill) : .clear + return isHovered ? Color(nsColor: .quaternaryFill) : .clear } } diff --git a/TablePro/Views/Acknowledgements/AcknowledgementsView.swift b/TablePro/Views/Acknowledgements/AcknowledgementsView.swift index c5d28efefc..ad67032652 100644 --- a/TablePro/Views/Acknowledgements/AcknowledgementsView.swift +++ b/TablePro/Views/Acknowledgements/AcknowledgementsView.swift @@ -39,7 +39,7 @@ struct AcknowledgementsView: View { } .listStyle(.sidebar) } else { - ContentUnavailableView( + UnavailableStateView( String(localized: "No License Information"), systemImage: "doc.text.magnifyingglass", description: Text(String(localized: "The list of open source libraries is missing from this build.")) @@ -62,7 +62,7 @@ struct AcknowledgementsView: View { if let inventory, let component = selectedComponent(in: inventory) { ComponentLicenseDetail(component: component, text: inventory.licenseText(for: component)) } else { - ContentUnavailableView( + UnavailableStateView( String(localized: "Select a Library"), systemImage: "sidebar.left", description: Text(String(localized: "TablePro includes these open source libraries. Pick one to read its license.")) diff --git a/TablePro/Views/Backup/BackupDatabaseFlow.swift b/TablePro/Views/Backup/BackupDatabaseFlow.swift index de29f1b89e..74bcd72eb9 100644 --- a/TablePro/Views/Backup/BackupDatabaseFlow.swift +++ b/TablePro/Views/Backup/BackupDatabaseFlow.swift @@ -18,8 +18,8 @@ struct BackupDatabaseFlow: View { /// to the database the window is browsing. var preselectedDatabases: Set = [] - @State private var model: BackupScopeModel - @State private var batch = NativeDumpBatch() + @StateObject private var model: BackupScopeModel + @StateObject private var batch = NativeDumpBatch() @State private var phase: Phase = .plan @State private var formatId: String @State private var directory: URL @@ -50,7 +50,7 @@ struct BackupDatabaseFlow: View { self.preselectedDatabases = preselectedDatabases let formats = NativeDumpRegistry.formats(for: connection.type) self._formatId = State(initialValue: formats.first?.id ?? "default") - self._model = State( + self._model = StateObject( wrappedValue: BackupScopeModel( connection: connection, objectScope: NativeDumpRegistry.descriptor(for: connection.type)?.objectScope diff --git a/TablePro/Views/Backup/BackupPlanSheet.swift b/TablePro/Views/Backup/BackupPlanSheet.swift index 789e30cb65..42a99cbad3 100644 --- a/TablePro/Views/Backup/BackupPlanSheet.swift +++ b/TablePro/Views/Backup/BackupPlanSheet.swift @@ -13,7 +13,7 @@ import UniformTypeIdentifiers /// and this flow used to present a save panel as a sub-sheet over the database picker, so the user /// answered "which database" before seeing anything about the file and could not get back. internal struct BackupPlanSheet: View { - internal let model: BackupScopeModel + @ObservedObject internal var model: BackupScopeModel internal let formats: [NativeDumpDescriptor.ArchiveFormat] @Binding internal var formatId: String @Binding internal var directory: URL diff --git a/TablePro/Views/Backup/BackupScopeModel.swift b/TablePro/Views/Backup/BackupScopeModel.swift index 398738b2e9..70a1915b26 100644 --- a/TablePro/Views/Backup/BackupScopeModel.swift +++ b/TablePro/Views/Backup/BackupScopeModel.swift @@ -3,8 +3,8 @@ // TablePro // +import Combine import Foundation -import Observation /// What the backup sheet is about to write, as the user has it set up. /// @@ -12,8 +12,7 @@ import Observation /// dump carries views, routines and sequences that a list of tables does not, so a tree with every /// box ticked resolves to `.wholeDatabase` rather than to an enumeration of every table in it. @MainActor -@Observable -final class BackupScopeModel { +final class BackupScopeModel: ObservableObject { enum ObjectLoad: Equatable { case notLoaded case loading @@ -56,8 +55,8 @@ final class BackupScopeModel { } } - private(set) var rows: [DatabaseRow] = [] - private(set) var isLoadingDatabases = true + @Published private(set) var rows: [DatabaseRow] = [] + @Published private(set) var isLoadingDatabases = true let connection: DatabaseConnection let objectScope: NativeDumpObjectScope diff --git a/TablePro/Views/Backup/RestoreDatabaseFlow.swift b/TablePro/Views/Backup/RestoreDatabaseFlow.swift index b30b44d81f..c71b871bab 100644 --- a/TablePro/Views/Backup/RestoreDatabaseFlow.swift +++ b/TablePro/Views/Backup/RestoreDatabaseFlow.swift @@ -12,7 +12,7 @@ struct RestoreDatabaseFlow: View { let initialDatabase: String let sourceURL: URL - @State private var service = NativeDumpService(kind: .restore) + @StateObject private var service = NativeDumpService(kind: .restore) @State private var phase: Phase = .resolvingTarget @State private var hostWindow: NSWindow? @@ -69,7 +69,7 @@ struct RestoreDatabaseFlow: View { .background { WindowAccessor { window in hostWindow = window } } - .onChange(of: serviceState) { _, newState in + .onChange(of: serviceState) { newState in handleServiceStateChange(newState) } .task { await resolveTarget() } diff --git a/TablePro/Views/Compare/CompareApplySheetView.swift b/TablePro/Views/Compare/CompareApplySheetView.swift index 8b0f914f3e..baae27cc69 100644 --- a/TablePro/Views/Compare/CompareApplySheetView.swift +++ b/TablePro/Views/Compare/CompareApplySheetView.swift @@ -42,7 +42,7 @@ internal struct CompareApplySheetView: View { } } - @Bindable internal var session: CompareSyncSession + @ObservedObject internal var session: CompareSyncSession internal let callback: (Choice) -> Void @AppStorage("structureCodeFontSize", store: AppStorageEnvironment.shared.defaults) private var fontSize = 13.0 @@ -319,7 +319,7 @@ internal struct CompareApplySheetView: View { private var warningsPane: some View { Group { if hazardStatements.isEmpty { - ContentUnavailableView { + UnavailableStateView { Label("No Warnings", systemImage: "checkmark.shield") } description: { Text(String(format: String(localized: "Nothing in this script destroys data in %@."), targetName)) diff --git a/TablePro/Views/Compare/CompareDataPlansView.swift b/TablePro/Views/Compare/CompareDataPlansView.swift index bebdc75b0c..156c8a3d46 100644 --- a/TablePro/Views/Compare/CompareDataPlansView.swift +++ b/TablePro/Views/Compare/CompareDataPlansView.swift @@ -15,7 +15,7 @@ import SwiftUI internal struct CompareDataPlansView: View { - @Bindable internal var session: CompareSyncSession + @ObservedObject internal var session: CompareSyncSession internal let onCompare: () -> Void @State private var sortOrder = [KeyPathComparator(\CompareDataPlanRow.tableName)] @@ -47,7 +47,7 @@ internal struct CompareDataPlansView: View { selectionHeader Divider() if visible.isEmpty { - ContentUnavailableView.search(text: session.searchText) + UnavailableStateView.search(text: session.searchText) .frame(maxWidth: .infinity, maxHeight: .infinity) } else { plansTable(visible) @@ -55,70 +55,91 @@ internal struct CompareDataPlansView: View { } } + @ViewBuilder private func plansTable(_ visible: [DataComparePlan]) -> some View { let groups = CompareDataPlanGrouping.groups( from: visible, grouping: session.grouping, sortedUsing: sortOrder ) let flatRows = CompareDataPlanGrouping.rows(from: visible, sortedUsing: sortOrder) - return Table(of: CompareDataPlanRow.self, selection: $session.selectedPlanId, sortOrder: $sortOrder) { - TableColumn("Include") { row in - includeCell(row) - } - .width(min: 56, ideal: 64) - - TableColumn("Table", value: \.tableName) { row in - Text(row.tableName) - .fontWeight(row.isGroup ? .semibold : .regular) - .lineLimit(1) - .truncationMode(.middle) - .help(row.tableName) +if #available(macOS 14.0, *) { + Table(of: CompareDataPlanRow.self, selection: $session.selectedPlanId, sortOrder: $sortOrder) { + planColumns + } rows: { + if session.grouping == .none { + ForEach(flatRows) { row in + SwiftUI.TableRow(row) + } + } else { + ForEach(groups) { group in + DisclosureTableRow(group.header) { + ForEach(group.rows) { row in + SwiftUI.TableRow(row) + } + } + } + } } - - TableColumn("Key") { row in - keyCell(row) + .contextMenu(forSelectionType: CompareDataPlanRow.ID.self) { selection in + planCommands(for: selection, groups: groups) } - - TableColumn("Scope") { row in - if let plan = row.plan { - Text(Self.scopeDescription(plan.scope)) - .foregroundStyle(.secondary) - .lineLimit(1) + } else { + Table(of: CompareDataPlanRow.self, selection: $session.selectedPlanId, sortOrder: $sortOrder) { + planColumns + } rows: { + /// A group reads as its header followed by its rows; what macOS 13 gives up is + /// collapsing it. + ForEach(session.grouping == .none ? flatRows : groups.flatMap { [$0.header] + $0.rows }) { row in + SwiftUI.TableRow(row) } } - - TableColumn("Insert") { row in - countCell(row.insertCount, kind: .insert) + .contextMenu(forSelectionType: CompareDataPlanRow.ID.self) { selection in + planCommands(for: selection, groups: groups) } + } + } - TableColumn("Update") { row in - countCell(row.updateCount, kind: .update) - } + @TableColumnBuilder> + private var planColumns: some TableColumnContent> { + TableColumn("Include") { row in + includeCell(row) + } + .width(min: 56, ideal: 64) - TableColumn("Delete") { row in - countCell(row.deleteCount, kind: .delete) - } + TableColumn("Table", value: \.tableName) { row in + Text(row.tableName) + .fontWeight(row.isGroup ? .semibold : .regular) + .lineLimit(1) + .truncationMode(.middle) + .help(row.tableName) + } - TableColumn("Same") { row in - countCell(row.identicalCount, kind: .identical) - } - } rows: { - if session.grouping == .none { - ForEach(flatRows) { row in - SwiftUI.TableRow(row) - } - } else { - ForEach(groups) { group in - DisclosureTableRow(group.header) { - ForEach(group.rows) { row in - SwiftUI.TableRow(row) - } - } - } + TableColumn("Key") { row in + keyCell(row) + } + + TableColumn("Scope") { row in + if let plan = row.plan { + Text(Self.scopeDescription(plan.scope)) + .foregroundStyle(.secondary) + .lineLimit(1) } } - .contextMenu(forSelectionType: CompareDataPlanRow.ID.self) { selection in - planCommands(for: selection, groups: groups) + + TableColumn("Insert") { row in + countCell(row.insertCount, kind: .insert) + } + + TableColumn("Update") { row in + countCell(row.updateCount, kind: .update) + } + + TableColumn("Delete") { row in + countCell(row.deleteCount, kind: .delete) + } + + TableColumn("Same") { row in + countCell(row.identicalCount, kind: .identical) } } @@ -318,7 +339,7 @@ internal struct CompareDataPlansView: View { /// than costing a Compare of its own. This state is what is left: no pair yet, the list on its /// way, or a pair with nothing in common. private var emptyState: some View { - ContentUnavailableView { + UnavailableStateView { Label(emptyTitle, systemImage: "tablecells") } description: { Text(emptyDescription) diff --git a/TablePro/Views/Compare/CompareDetailView.swift b/TablePro/Views/Compare/CompareDetailView.swift index cc29c1cceb..e3accc257d 100644 --- a/TablePro/Views/Compare/CompareDetailView.swift +++ b/TablePro/Views/Compare/CompareDetailView.swift @@ -12,7 +12,7 @@ import SwiftUI internal struct CompareDetailView: View { - @Bindable internal var session: CompareSyncSession + @ObservedObject internal var session: CompareSyncSession internal let onCompare: () -> Void internal let onGenerateScript: () -> Void @@ -55,7 +55,7 @@ internal struct CompareDetailView: View { } internal struct CompareDefinitionsPane: View { - @Bindable internal var session: CompareSyncSession + @ObservedObject internal var session: CompareSyncSession internal var body: some View { if let result = session.selectedResult { @@ -63,13 +63,13 @@ internal struct CompareDefinitionsPane: View { definitionBody(result) } } else if session.mode == .data { - ContentUnavailableView { + UnavailableStateView { Label("Definitions Compare Structure", systemImage: "doc.text.magnifyingglass") } description: { Text("Switch the comparison to Structure to see definitions.") } } else { - ContentUnavailableView { + UnavailableStateView { Label("No Object Selected", systemImage: "doc.text") } description: { Text("Select an object to see its definition on both sides.") diff --git a/TablePro/Views/Compare/CompareEndpointToolbarController.swift b/TablePro/Views/Compare/CompareEndpointToolbarController.swift index e2604c30a3..d7a7a04e09 100644 --- a/TablePro/Views/Compare/CompareEndpointToolbarController.swift +++ b/TablePro/Views/Compare/CompareEndpointToolbarController.swift @@ -106,6 +106,7 @@ internal final class CompareEndpointToolbarController: NSObject { let shown = PopoverPresenter.show( relativeTo: anchor, + in: windowProvider(), contentSize: DatabaseEndpointPicker.contentSize, behavior: .transient ) { dismiss in diff --git a/TablePro/Views/Compare/CompareOptionsView.swift b/TablePro/Views/Compare/CompareOptionsView.swift index ff99cfa467..6dae4c6ee4 100644 --- a/TablePro/Views/Compare/CompareOptionsView.swift +++ b/TablePro/Views/Compare/CompareOptionsView.swift @@ -13,7 +13,7 @@ import SwiftUI import TableProPluginKit internal struct CompareOptionsView: View { - @Bindable internal var session: CompareSyncSession + @ObservedObject internal var session: CompareSyncSession @State private var savedProfiles: [CompareSyncProfile] = [] @State private var newProfileName = "" @@ -31,14 +31,14 @@ internal struct CompareOptionsView: View { .onAppear { savedProfiles = session.savedProfiles } - .onChange(of: session.includedKinds) { + .onChange(of: session.includedKinds) { _ in guard session.mode == .structure else { return } session.resetComparison(keepingTableScopes: true) } - .onChange(of: session.structureOptions) { + .onChange(of: session.structureOptions) { _ in session.resetComparison(keepingTableScopes: true) } - .onChange(of: session.dataOptions) { previous, current in + .onValueChange(of: session.dataOptions) { previous, current in applyDataOptionChange(from: previous, to: current) } } diff --git a/TablePro/Views/Compare/CompareProgressView.swift b/TablePro/Views/Compare/CompareProgressView.swift index 4d1e7d95f9..b4984522e7 100644 --- a/TablePro/Views/Compare/CompareProgressView.swift +++ b/TablePro/Views/Compare/CompareProgressView.swift @@ -15,7 +15,7 @@ import SwiftUI internal struct CompareProgressView: View { - @Bindable internal var session: CompareSyncSession + @ObservedObject internal var session: CompareSyncSession internal var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -88,7 +88,7 @@ internal struct CompareProgressView: View { /// situation the user must resolve before continuing and allows one at a time, and a restored /// window can raise several at once. internal struct CompareMessageBanner: View { - @Bindable internal var session: CompareSyncSession + @ObservedObject internal var session: CompareSyncSession @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor diff --git a/TablePro/Views/Compare/CompareResultsView.swift b/TablePro/Views/Compare/CompareResultsView.swift index 0a633cce2f..a1928bedbe 100644 --- a/TablePro/Views/Compare/CompareResultsView.swift +++ b/TablePro/Views/Compare/CompareResultsView.swift @@ -12,7 +12,7 @@ import SwiftUI internal struct CompareResultsView: View { - @Bindable internal var session: CompareSyncSession + @ObservedObject internal var session: CompareSyncSession internal let onCompare: () -> Void @State private var sortOrder = [KeyPathComparator(\CompareResultRow.objectName)] @@ -81,6 +81,9 @@ internal struct CompareResultsView: View { // MARK: - Table + /// `DisclosureTableRow` is macOS 14, and `@TableRowBuilder` rejects an `if #available` + /// inside it, so the whole table branches instead. The columns are shared. + @ViewBuilder private func resultsTable( visible: [CompareObjectResult], uncomparable: [CompareObjectResult] @@ -92,60 +95,83 @@ internal struct CompareResultsView: View { let unreadable = CompareResultGrouping.uncomparableGroup(from: uncomparable, sortedUsing: sortOrder) let selectableGroups = groups + (unreadable.map { [$0] } ?? []) - return Table(of: CompareResultRow.self, selection: $session.selectedObjectId, sortOrder: $sortOrder) { - TableColumn("Include") { row in - includeToggle(for: row) - } - .width(min: 52, ideal: 60) - - TableColumn("Object", value: \.objectName) { row in - Text(row.objectName) - .fontWeight(row.isGroup ? .semibold : .regular) - .lineLimit(1) - .truncationMode(.middle) - .help(row.objectName) - } - - TableColumn("Type", value: \.typeName) { row in - Text(row.typeName) - .foregroundStyle(.secondary) - .lineLimit(1) - } - - TableColumn("Difference", value: \.differenceName) { row in - differenceCell(row) - } - - TableColumn("Change", value: \.changeSummary) { row in - Text(row.changeSummary) - .foregroundStyle(.secondary) - .lineLimit(1) - .help(row.changeSummary) - } - } rows: { - if session.grouping == .none { - ForEach(flatRows) { row in - SwiftUI.TableRow(row) + if #available(macOS 14.0, *) { + Table(of: CompareResultRow.self, selection: $session.selectedObjectId, sortOrder: $sortOrder) { + resultColumns + } rows: { + if session.grouping == .none { + ForEach(flatRows) { row in + SwiftUI.TableRow(row) + } + } else { + ForEach(groups) { group in + DisclosureTableRow(group.header) { + ForEach(group.rows) { row in + SwiftUI.TableRow(row) + } + } + } } - } else { - ForEach(groups) { group in - DisclosureTableRow(group.header) { - ForEach(group.rows) { row in + if let unreadable { + DisclosureTableRow(unreadable.header) { + ForEach(unreadable.rows) { row in SwiftUI.TableRow(row) } } } } - if let unreadable { - DisclosureTableRow(unreadable.header) { - ForEach(unreadable.rows) { row in - SwiftUI.TableRow(row) - } + .contextMenu(forSelectionType: CompareResultRow.ID.self) { selection in + inclusionCommands(for: selection, groups: selectableGroups) + } + } else { + Table(of: CompareResultRow.self, selection: $session.selectedObjectId, sortOrder: $sortOrder) { + resultColumns + } rows: { + /// A group reads as its header followed by its rows; what macOS 13 gives up is + /// collapsing it. + ForEach( + (session.grouping == .none ? flatRows : groups.flatMap { [$0.header] + $0.rows }) + + (unreadable.map { [$0.header] + $0.rows } ?? []) + ) { row in + SwiftUI.TableRow(row) } } + .contextMenu(forSelectionType: CompareResultRow.ID.self) { selection in + inclusionCommands(for: selection, groups: selectableGroups) + } } - .contextMenu(forSelectionType: CompareResultRow.ID.self) { selection in - inclusionCommands(for: selection, groups: selectableGroups) + } + + @TableColumnBuilder> + private var resultColumns: some TableColumnContent> { + TableColumn("Include") { row in + includeToggle(for: row) + } + .width(min: 52, ideal: 60) + + TableColumn("Object", value: \.objectName) { row in + Text(row.objectName) + .fontWeight(row.isGroup ? .semibold : .regular) + .lineLimit(1) + .truncationMode(.middle) + .help(row.objectName) + } + + TableColumn("Type", value: \.typeName) { row in + Text(row.typeName) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + TableColumn("Difference", value: \.differenceName) { row in + differenceCell(row) + } + + TableColumn("Change", value: \.changeSummary) { row in + Text(row.changeSummary) + .foregroundStyle(.secondary) + .lineLimit(1) + .help(row.changeSummary) } } @@ -246,7 +272,7 @@ internal struct CompareResultsView: View { /// button leaves the user with no way to work out what is missing, which the HIG asks an app not /// to do. private var noReportState: some View { - ContentUnavailableView { + UnavailableStateView { Label("No Comparison Yet", systemImage: "arrow.left.arrow.right.circle") } description: { Text(noReportDescription) @@ -265,15 +291,15 @@ internal struct CompareResultsView: View { @ViewBuilder private var emptyResultState: some View { if session.report?.differenceCount == 0 { - ContentUnavailableView { + UnavailableStateView { Label("No Differences", systemImage: "equal.circle") } description: { Text("Every object that was compared matches.") } } else if !session.searchText.isEmpty { - ContentUnavailableView.search(text: session.searchText) + UnavailableStateView.search(text: session.searchText) } else { - ContentUnavailableView { + UnavailableStateView { Label("Nothing to Show", systemImage: "line.3.horizontal.decrease.circle") } description: { Text("The object types taking part in the comparison are set in Options.") diff --git a/TablePro/Views/Compare/CompareRowDiffPane.swift b/TablePro/Views/Compare/CompareRowDiffPane.swift index 333fc6129d..828cd18105 100644 --- a/TablePro/Views/Compare/CompareRowDiffPane.swift +++ b/TablePro/Views/Compare/CompareRowDiffPane.swift @@ -75,7 +75,7 @@ internal enum RowDiffFilter: String, CaseIterable, Hashable { } internal struct CompareRowDiffPane: View { - @Bindable internal var session: CompareSyncSession + @ObservedObject internal var session: CompareSyncSession internal let onCompare: () -> Void @State private var filter: RowDiffFilter = .difference @@ -84,13 +84,13 @@ internal struct CompareRowDiffPane: View { if let plan = session.selectedPlan { planBody(plan) } else if session.mode == .structure { - ContentUnavailableView { + UnavailableStateView { Label("Rows Compare Data", systemImage: "tablecells") } description: { Text("Switch the comparison to Data to see row differences.") } } else { - ContentUnavailableView { + UnavailableStateView { Label("No Table Selected", systemImage: "tablecells") } description: { Text("Select a table to see its row differences.") @@ -277,7 +277,7 @@ internal struct CompareRowDiffPane: View { if let summary = plan.summary { let entries = filter.entries(in: summary) if entries.isEmpty { - ContentUnavailableView { + UnavailableStateView { Label("No Rows Match", systemImage: "line.3.horizontal.decrease.circle") } description: { Text("Change the filter to see the other rows.") @@ -286,7 +286,7 @@ internal struct CompareRowDiffPane: View { CompareRowGrid(session: session, plan: plan, filter: filter, entries: entries) } } else { - ContentUnavailableView { + UnavailableStateView { Label("Not Compared Yet", systemImage: "arrow.clockwise") } description: { Text(notComparedDescription(for: plan)) diff --git a/TablePro/Views/Compare/CompareRowGrid.swift b/TablePro/Views/Compare/CompareRowGrid.swift index 4635567fea..d866950c19 100644 --- a/TablePro/Views/Compare/CompareRowGrid.swift +++ b/TablePro/Views/Compare/CompareRowGrid.swift @@ -173,7 +173,7 @@ internal final class CompareRowGridModel: DataGridViewDelegate { } internal struct CompareRowGrid: View { - @Bindable internal var session: CompareSyncSession + @ObservedObject internal var session: CompareSyncSession internal let plan: DataComparePlan internal let filter: RowDiffFilter internal let entries: [RowDiffEntry] @@ -213,7 +213,7 @@ internal struct CompareRowGrid: View { /// repaints the checkboxes rather than leaving them until the next click or scroll. contentRevision: key.hashValue ^ plan.excludedRowKeys.hashValue ) - .onChange(of: key) { + .onChange(of: key) { _ in selectedRows = [] } } diff --git a/TablePro/Views/Compare/CompareScriptPane.swift b/TablePro/Views/Compare/CompareScriptPane.swift index 184dcac97d..9b2776cac9 100644 --- a/TablePro/Views/Compare/CompareScriptPane.swift +++ b/TablePro/Views/Compare/CompareScriptPane.swift @@ -14,7 +14,7 @@ import SwiftUI import UniformTypeIdentifiers internal struct CompareScriptPane: View { - @Bindable internal var session: CompareSyncSession + @ObservedObject internal var session: CompareSyncSession internal let onGenerateScript: () -> Void @AppStorage("structureCodeFontSize", store: AppStorageEnvironment.shared.defaults) private var fontSize = 13.0 @@ -155,7 +155,7 @@ internal struct CompareScriptPane: View { // MARK: - Empty state private var emptyState: some View { - ContentUnavailableView { + UnavailableStateView { Label("No Script Yet", systemImage: "doc.plaintext") } description: { Text(emptyDescription) @@ -177,7 +177,7 @@ internal struct CompareScriptPane: View { internal struct CompareHeldBackStatementRow: View { internal let statement: SyncStatement - @Bindable internal var session: CompareSyncSession + @ObservedObject internal var session: CompareSyncSession internal var body: some View { VStack(alignment: .leading, spacing: 4) { diff --git a/TablePro/Views/Compare/CompareStatusBar.swift b/TablePro/Views/Compare/CompareStatusBar.swift index 7dee88edf7..1bfd007a0d 100644 --- a/TablePro/Views/Compare/CompareStatusBar.swift +++ b/TablePro/Views/Compare/CompareStatusBar.swift @@ -21,7 +21,7 @@ import SwiftUI internal struct CompareStatusBar: View { - @Bindable internal var session: CompareSyncSession + @ObservedObject internal var session: CompareSyncSession @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor diff --git a/TablePro/Views/Compare/CompareSyncWindowController.swift b/TablePro/Views/Compare/CompareSyncWindowController.swift index 4c18c863d2..e017fe0425 100644 --- a/TablePro/Views/Compare/CompareSyncWindowController.swift +++ b/TablePro/Views/Compare/CompareSyncWindowController.swift @@ -702,6 +702,7 @@ internal final class CompareSyncWindowController: NSWindowController, guard let item else { return } PopoverPresenter.show( relativeTo: item, + in: window, contentSize: NSSize(width: 420, height: 520) ) { _ in CompareOptionsView(session: self.session) diff --git a/TablePro/Views/Compare/CompareTableScopeEditor.swift b/TablePro/Views/Compare/CompareTableScopeEditor.swift index 958227c830..fa2a55c5f1 100644 --- a/TablePro/Views/Compare/CompareTableScopeEditor.swift +++ b/TablePro/Views/Compare/CompareTableScopeEditor.swift @@ -13,7 +13,7 @@ import SwiftUI internal struct CompareTableScopeEditor: View { - @Bindable internal var session: CompareSyncSession + @ObservedObject internal var session: CompareSyncSession internal let plan: DataComparePlan private enum RowLimitMode: Hashable { @@ -88,14 +88,14 @@ internal struct CompareTableScopeEditor: View { .padding(.vertical, 8) .disabled(!session.canChangeSetup) .onAppear(perform: prepare) - .onChange(of: plan.id) { + .onChange(of: plan.id) { _ in commitSourceFilter() commitTargetFilter() prepare() } - .onChange(of: plan.scope.sourceFilter) { syncDraftsFromScope() } - .onChange(of: plan.scope.targetFilter) { syncDraftsFromScope() } - .onChange(of: focusedField) { previous, current in + .onChange(of: plan.scope.sourceFilter) { _ in syncDraftsFromScope() } + .onChange(of: plan.scope.targetFilter) { _ in syncDraftsFromScope() } + .onValueChange(of: focusedField) { previous, current in if previous == sourceFieldIdentity, current != sourceFieldIdentity { commitSourceFilter() } diff --git a/TablePro/Views/Compare/CompareWindowContentView.swift b/TablePro/Views/Compare/CompareWindowContentView.swift index 927b19b833..fb9f825d0f 100644 --- a/TablePro/Views/Compare/CompareWindowContentView.swift +++ b/TablePro/Views/Compare/CompareWindowContentView.swift @@ -13,7 +13,7 @@ import SwiftUI internal struct CompareWindowContentView: View { - @Bindable internal var session: CompareSyncSession + @ObservedObject internal var session: CompareSyncSession internal var onCompare: () -> Void internal var onGenerateScript: () -> Void internal var onApply: () -> Void @@ -60,7 +60,7 @@ internal struct CompareWindowContentView: View { /// It sits at the top rather than the bottom for the reason the HIG gives about bottom bars, and /// it is deliberately not an action bar: it carries state and a Cancel, never a primary action. internal struct CompareStatusStrip: View { - @Bindable internal var session: CompareSyncSession + @ObservedObject internal var session: CompareSyncSession internal var body: some View { HStack(spacing: 12) { diff --git a/TablePro/Views/Components/ColorPaletteView.swift b/TablePro/Views/Components/ColorPaletteView.swift index e274b08736..f26446b7c7 100644 --- a/TablePro/Views/Components/ColorPaletteView.swift +++ b/TablePro/Views/Components/ColorPaletteView.swift @@ -59,7 +59,7 @@ private struct ColorSwatchButtonStyle: ButtonStyle { .scaleEffect(configuration.isPressed ? 0.88 : 1) .background( Circle() - .fill(Color(nsColor: .quaternarySystemFill)) + .fill(Color(nsColor: .quaternaryFill)) .opacity(isHovering && !configuration.isPressed ? 1 : 0) ) .onHover { isHovering = $0 } diff --git a/TablePro/Views/Components/DatabaseEndpointPicker.swift b/TablePro/Views/Components/DatabaseEndpointPicker.swift index 543d16830d..aa29460932 100644 --- a/TablePro/Views/Components/DatabaseEndpointPicker.swift +++ b/TablePro/Views/Components/DatabaseEndpointPicker.swift @@ -57,7 +57,7 @@ internal struct DatabaseEndpointPicker: View { internal static let contentSize = NSSize(width: 320, height: 400) - @State private var model = DatabaseEndpointPickerModel() + @StateObject private var model = DatabaseEndpointPickerModel() @State private var path: [DatabaseEndpointRoute] = [] @State private var connections: [DatabaseConnection] = [] @State private var filter = "" @@ -74,7 +74,7 @@ internal struct DatabaseEndpointPicker: View { .onAppear { connections = ConnectionStorage.shared.loadConnections() } /// Each level filters its own list, so moving between them starts clean rather than /// arriving at a database list already narrowed by a connection's name. - .onChange(of: path) { _, _ in filter = "" } + .onChange(of: path) { _ in filter = "" } } // MARK: - Search @@ -104,7 +104,7 @@ internal struct DatabaseEndpointPicker: View { } private var noMatchesPane: some View { - ContentUnavailableView { + UnavailableStateView { Label("No Matches", systemImage: "magnifyingglass") } description: { Text("Nothing here matches the search.") @@ -116,7 +116,7 @@ internal struct DatabaseEndpointPicker: View { private var connectionList: some View { Group { if connections.isEmpty { - ContentUnavailableView { + UnavailableStateView { Label("No Saved Connections", systemImage: "externaldrive.badge.questionmark") } description: { Text("Add a connection first.") @@ -249,7 +249,7 @@ internal struct DatabaseEndpointPicker: View { failurePane(message) { await model.loadSchemas(for: endpoint, connection: connection, reload: true) } case let .loaded(names): if names.isEmpty { - ContentUnavailableView { + UnavailableStateView { Label("No Schemas", systemImage: "tray") } description: { Text("This database reports no schemas.") @@ -306,7 +306,7 @@ internal struct DatabaseEndpointPicker: View { } private func failurePane(_ message: String, retry: @escaping () async -> Void) -> some View { - ContentUnavailableView { + UnavailableStateView { Label("Cannot Read This Connection", systemImage: "exclamationmark.triangle") } description: { RevealedTextView(message) diff --git a/TablePro/Views/Components/DatabaseEndpointPickerModel.swift b/TablePro/Views/Components/DatabaseEndpointPickerModel.swift index 2254f56a7b..4d11f8c58f 100644 --- a/TablePro/Views/Components/DatabaseEndpointPickerModel.swift +++ b/TablePro/Views/Components/DatabaseEndpointPickerModel.swift @@ -12,6 +12,7 @@ // their server has no databases. // +import Combine import Foundation internal enum DatabaseEndpointListState: Equatable { @@ -21,13 +22,12 @@ internal enum DatabaseEndpointListState: Equatable { } @MainActor -@Observable -internal final class DatabaseEndpointPickerModel { - private var databaseStates: [UUID: DatabaseEndpointListState] = [:] - private var schemaStates: [String: DatabaseEndpointListState] = [:] - @ObservationIgnored private var inFlight: Set = [] - @ObservationIgnored private let databaseLoader: (DatabaseConnection) async throws -> [String] - @ObservationIgnored private let schemaLoader: (DatabaseEndpoint, DatabaseConnection) async throws -> [String] +internal final class DatabaseEndpointPickerModel: ObservableObject { + @Published private var databaseStates: [UUID: DatabaseEndpointListState] = [:] + @Published private var schemaStates: [String: DatabaseEndpointListState] = [:] + private var inFlight: Set = [] + private let databaseLoader: (DatabaseConnection) async throws -> [String] + private let schemaLoader: (DatabaseEndpoint, DatabaseConnection) async throws -> [String] internal convenience init() { let metadata = CompareMetadataService() diff --git a/TablePro/Views/Components/EmptyStateView.swift b/TablePro/Views/Components/EmptyStateView.swift index a972b646df..6ef12cd743 100644 --- a/TablePro/Views/Components/EmptyStateView.swift +++ b/TablePro/Views/Components/EmptyStateView.swift @@ -45,7 +45,7 @@ struct EmptyStateView: View { } var body: some View { - ContentUnavailableView { + UnavailableStateView { Label(title, systemImage: icon) } description: { if let description { diff --git a/TablePro/Views/Components/MagnifiableCanvasView.swift b/TablePro/Views/Components/MagnifiableCanvasView.swift index ba4aaced7c..082cb52217 100644 --- a/TablePro/Views/Components/MagnifiableCanvasView.swift +++ b/TablePro/Views/Components/MagnifiableCanvasView.swift @@ -9,16 +9,16 @@ // import AppKit +import Combine import SwiftUI @MainActor -@Observable -final class DiagramViewportController { - private(set) var magnification: CGFloat = 1.0 +final class DiagramViewportController: ObservableObject { + @Published private(set) var magnification: CGFloat = 1.0 - @ObservationIgnored private weak var scrollView: DiagramScrollView? - @ObservationIgnored private var magnificationObservation: NSKeyValueObservation? - @ObservationIgnored private var savedDocumentOrigin: CGPoint? + private weak var scrollView: DiagramScrollView? + private var magnificationObservation: NSKeyValueObservation? + private var savedDocumentOrigin: CGPoint? var visibleDocumentRect: CGRect { scrollView?.documentVisibleRect ?? .zero diff --git a/TablePro/Views/Components/PaginationControlsView.swift b/TablePro/Views/Components/PaginationControlsView.swift index ef8038aba8..188d77fe47 100644 --- a/TablePro/Views/Components/PaginationControlsView.swift +++ b/TablePro/Views/Components/PaginationControlsView.swift @@ -66,7 +66,7 @@ struct PaginationControlsView: View { navigationCluster } } - .onChange(of: tabId) { _, _ in + .onChange(of: tabId) { _ in showJumpPopover = false showCustomPopover = false jumpPage = nil diff --git a/TablePro/Views/Components/PopoverPresenter.swift b/TablePro/Views/Components/PopoverPresenter.swift index 007ecca902..a74f1a356e 100644 --- a/TablePro/Views/Components/PopoverPresenter.swift +++ b/TablePro/Views/Components/PopoverPresenter.swift @@ -37,6 +37,11 @@ enum PopoverPresenter { /// The caller must have resolved `toolbarItem` out of a visible toolbar. AppKit throws /// `NSInvalidArgumentException` when it cannot locate the item, which Swift cannot catch, so /// the check belongs at the call site as a precondition rather than here as error handling. + /// `show(relativeTo: NSToolbarItem)` is macOS 14, and there is no stand-in: an item whose + /// view AppKit generates reports `view` as nil, so there is nothing to anchor on below it. + /// Callers resolve their anchor through `ToolbarSwitcherPresenter.anchor`, which answers nil + /// on macOS 13 so they take their own fallback instead. + @available(macOS 14.0, *) @discardableResult static func show( relativeTo toolbarItem: NSToolbarItem, @@ -49,6 +54,29 @@ enum PopoverPresenter { return popover } + /// Presents from a toolbar item where AppKit allows it, and from the top of the window's + /// content where it does not. `show(relativeTo: NSToolbarItem)` is macOS 14, and an item whose + /// view AppKit generates reports `view` as nil, so macOS 13 has nothing on the toolbar to point + /// at. Anchoring under the titlebar keeps the popover in the same place the item sits. + @discardableResult + static func show( + relativeTo toolbarItem: NSToolbarItem, + in window: NSWindow?, + contentSize: NSSize? = nil, + behavior: NSPopover.Behavior = .semitransient, + @ViewBuilder content: (_ dismiss: @escaping () -> Void) -> Content + ) -> NSPopover { + let popover = make(contentSize: contentSize, behavior: behavior, content: content) + if #available(macOS 14.0, *) { + popover.show(relativeTo: toolbarItem) + } else if let host = window?.contentView { + let width = contentSize?.width ?? host.bounds.width + let rect = NSRect(x: host.bounds.midX - width / 2, y: host.bounds.maxY - 1, width: width, height: 1) + popover.show(relativeTo: rect, of: host, preferredEdge: .maxY) + } + return popover + } + /// Builds the popover without presenting it, so its sizing can be asserted in tests. /// /// `NSPopover` computes where to put itself from `contentSize` at `show` time, and its header's diff --git a/TablePro/Views/Components/SyncStatusIndicator.swift b/TablePro/Views/Components/SyncStatusIndicator.swift index 244e41c084..d5892f84e9 100644 --- a/TablePro/Views/Components/SyncStatusIndicator.swift +++ b/TablePro/Views/Components/SyncStatusIndicator.swift @@ -9,7 +9,7 @@ import TableProSyncTransport struct SyncStatusIndicator: View { let onActivateLicense: () -> Void - private let syncCoordinator = SyncCoordinator.shared + @ObservedObject private var syncCoordinator = SyncCoordinator.shared var body: some View { if shouldShow { @@ -18,8 +18,8 @@ struct SyncStatusIndicator: View { } label: { HStack(spacing: 4) { Image(systemName: iconName) - .contentTransition(.symbolEffect(.replace)) - .symbolEffect(.pulse, isActive: syncCoordinator.syncStatus.isSyncing) + .symbolReplaceTransition() + .pulsingSymbol(isActive: syncCoordinator.syncStatus.isSyncing) Text(statusLabel) .contentTransition(.numericText()) } diff --git a/TablePro/Views/Components/UnavailableStateView.swift b/TablePro/Views/Components/UnavailableStateView.swift new file mode 100644 index 0000000000..faed90014d --- /dev/null +++ b/TablePro/Views/Components/UnavailableStateView.swift @@ -0,0 +1,85 @@ +// +// UnavailableStateView.swift +// TablePro +// +// Stands in for `ContentUnavailableView`, which is macOS 14. The three initialisers +// mirror the system view's, so the call sites read the same and move back when the +// deployment target rises. +// + +import SwiftUI + +internal struct UnavailableStateView: View { + private let label: Label + private let description: Description + private let actions: Actions + + internal init( + @ViewBuilder label: () -> Label, + @ViewBuilder description: () -> Description = { EmptyView() }, + @ViewBuilder actions: () -> Actions = { EmptyView() } + ) { + self.label = label() + self.description = description() + self.actions = actions() + } + + internal var body: some View { + VStack(spacing: 10) { + label + .labelStyle(UnavailableLabelStyle()) + + description + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + actions + .padding(.top, 6) + } + .padding(.horizontal, 32) + .frame(maxWidth: 420) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +internal extension UnavailableStateView where Label == SwiftUI.Label, Description == Text?, Actions == EmptyView { + init(_ title: LocalizedStringKey, systemImage: String, description: Text? = nil) { + self.init(label: { SwiftUI.Label(title, systemImage: systemImage) }, description: { description }, actions: { EmptyView() }) + } + + init(_ title: String, systemImage: String, description: Text? = nil) { + self.init(label: { SwiftUI.Label(title, systemImage: systemImage) }, description: { description }, actions: { EmptyView() }) + } +} + +internal extension UnavailableStateView where Label == SwiftUI.Label, Description == Text?, Actions == EmptyView { + static func search(text: String) -> UnavailableStateView { + UnavailableStateView( + String(localized: "No Results"), + systemImage: "magnifyingglass", + description: text.isEmpty + ? nil + : Text(String(format: String(localized: "No results for \"%@\"."), text)) + ) + } + + static var search: UnavailableStateView { + UnavailableStateView(String(localized: "No Results"), systemImage: "magnifyingglass") + } +} + +private struct UnavailableLabelStyle: LabelStyle { + func makeBody(configuration: Configuration) -> some View { + VStack(spacing: 8) { + configuration.icon + .font(.system(size: 34, weight: .light)) + .foregroundStyle(.secondary) + + configuration.title + .font(.title3.weight(.semibold)) + .multilineTextAlignment(.center) + } + } +} diff --git a/TablePro/Views/Connection/ConnectingStateView.swift b/TablePro/Views/Connection/ConnectingStateView.swift index a36a693c81..0c8660914a 100644 --- a/TablePro/Views/Connection/ConnectingStateView.swift +++ b/TablePro/Views/Connection/ConnectingStateView.swift @@ -21,7 +21,7 @@ internal struct ConnectingStateView: View { internal let connection: DatabaseConnection internal let onCancel: () -> Void - @State private var observer: ConnectionStageObserver + @StateObject private var observer: ConnectionStageObserver @State private var showsCard = false /// The description line keeps its height whether or not it has anything to say, so the bar @@ -31,7 +31,7 @@ internal struct ConnectingStateView: View { internal init(connection: DatabaseConnection, onCancel: @escaping () -> Void) { self.connection = connection self.onCancel = onCancel - _observer = State(wrappedValue: ConnectionStageObserver(connectionId: connection.id)) + _observer = StateObject(wrappedValue: ConnectionStageObserver(connectionId: connection.id)) } internal var body: some View { @@ -52,7 +52,7 @@ internal struct ConnectingStateView: View { /// Attached out here rather than to the card, so a step that lands before the card does is /// still spoken. VoiceOver is told what is happening from the first stage; the card is held /// back only because a picture nobody has time to read is worth less than a still window. - .onChange(of: observer.stage) { _, newStage in + .onChange(of: observer.stage) { newStage in guard let newStage else { return } announce(newStage) } @@ -135,8 +135,8 @@ internal struct ConnectingStateView: View { /// Posted per step rather than continuously. `updatesFrequently` is documented as a hint to /// poll, which is the wrong shape for a handful of discrete transitions. private func announce(_ stage: ConnectionStage) { - AccessibilityNotification.Announcement( + AccessibilityAnnouncement.post( ConnectionStageLabelFormatter.announcement(for: stage, connection: connection) - ).post() + ) } } diff --git a/TablePro/Views/Connection/ConnectionGroupPicker.swift b/TablePro/Views/Connection/ConnectionGroupPicker.swift index dc6f40545a..5fa88e1b5e 100644 --- a/TablePro/Views/Connection/ConnectionGroupPicker.swift +++ b/TablePro/Views/Connection/ConnectionGroupPicker.swift @@ -124,8 +124,8 @@ struct CreateGroupSheet: View { } .padding(20) .frame(width: 300) - .onChange(of: groupName) { _, _ in errorMessage = nil } - .onChange(of: selectedParentId) { _, _ in errorMessage = nil } + .onChange(of: groupName) { _ in errorMessage = nil } + .onChange(of: selectedParentId) { _ in errorMessage = nil } .onAppear { allGroups = GroupStorage.shared.loadGroups() selectedParentId = initialParentId diff --git a/TablePro/Views/Connection/ConnectionTagEditor.swift b/TablePro/Views/Connection/ConnectionTagEditor.swift index c0771619c4..8ef0df9530 100644 --- a/TablePro/Views/Connection/ConnectionTagEditor.swift +++ b/TablePro/Views/Connection/ConnectionTagEditor.swift @@ -217,7 +217,7 @@ private struct CreateTagSheet: View { } .padding(20) .frame(width: 300) - .onChange(of: tagName) { _, _ in errorMessage = nil } + .onChange(of: tagName) { _ in errorMessage = nil } .onExitCommand { dismiss() } diff --git a/TablePro/Views/Connection/ConnectionTypeIcon.swift b/TablePro/Views/Connection/ConnectionTypeIcon.swift index fd0e602daf..e24c6405d0 100644 --- a/TablePro/Views/Connection/ConnectionTypeIcon.swift +++ b/TablePro/Views/Connection/ConnectionTypeIcon.swift @@ -21,7 +21,7 @@ internal struct ConnectionTypeIcon: View { if isSystemSymbol { Image(systemName: iconName) .symbolRenderingMode(.hierarchical) - .symbolEffect(.pulse, options: .repeating, isActive: pulses) + .pulsingSymbol(isActive: pulses) } else { Image(iconName) .resizable() diff --git a/TablePro/Views/Connection/ConnectionUnavailableView.swift b/TablePro/Views/Connection/ConnectionUnavailableView.swift index 5e4e7849af..fd8084a6a7 100644 --- a/TablePro/Views/Connection/ConnectionUnavailableView.swift +++ b/TablePro/Views/Connection/ConnectionUnavailableView.swift @@ -13,7 +13,7 @@ internal struct ConnectionUnavailableView: View { internal let onManageConnections: () -> Void internal var body: some View { - ContentUnavailableView { + UnavailableStateView { Label { Text(headline) } icon: { diff --git a/TablePro/Views/Connection/DeeplinkImportSheet.swift b/TablePro/Views/Connection/DeeplinkImportSheet.swift index 6080d7c9aa..efff96ff65 100644 --- a/TablePro/Views/Connection/DeeplinkImportSheet.swift +++ b/TablePro/Views/Connection/DeeplinkImportSheet.swift @@ -33,7 +33,7 @@ struct DeeplinkImportSheet: View { Section(String(localized: "Connection")) { TextField(String(localized: "Name"), text: $editableName) - .onChange(of: editableName) { checkDuplicate() } + .onChange(of: editableName) { _ in checkDuplicate() } LabeledContent(String(localized: "Host")) { Text(hostDisplay) diff --git a/TablePro/Views/Connection/HostListFieldRow.swift b/TablePro/Views/Connection/HostListFieldRow.swift index 55ce25eceb..eed52c8431 100644 --- a/TablePro/Views/Connection/HostListFieldRow.swift +++ b/TablePro/Views/Connection/HostListFieldRow.swift @@ -75,7 +75,7 @@ struct HostListFieldRow: View { Text(label) } .onAppear { parseValue() } - .onChange(of: value) { parseValue() } + .onChange(of: value) { _ in parseValue() } } /// A plugin can declare a per-field example, which matters when one form has two host lists diff --git a/TablePro/Views/Connection/ImportFromAWS/AWSDiscoveryConfigurationStep.swift b/TablePro/Views/Connection/ImportFromAWS/AWSDiscoveryConfigurationStep.swift index 941806cec1..91cf4b9786 100644 --- a/TablePro/Views/Connection/ImportFromAWS/AWSDiscoveryConfigurationStep.swift +++ b/TablePro/Views/Connection/ImportFromAWS/AWSDiscoveryConfigurationStep.swift @@ -2,7 +2,7 @@ import SwiftUI import TableProPluginKit struct AWSDiscoveryConfigurationStep: View { - @Bindable var session: AWSDiscoverySession + @ObservedObject var session: AWSDiscoverySession let onStart: () -> Void let onCancel: () -> Void diff --git a/TablePro/Views/Connection/PasswordPromptToggle.swift b/TablePro/Views/Connection/PasswordPromptToggle.swift index e6295d1167..0f9e77b667 100644 --- a/TablePro/Views/Connection/PasswordPromptToggle.swift +++ b/TablePro/Views/Connection/PasswordPromptToggle.swift @@ -31,7 +31,7 @@ struct PasswordPromptToggle: View { : String(localized: "Prompt for password"), isOn: $promptForPassword ) - .onChange(of: promptForPassword) { _, newValue in + .onChange(of: promptForPassword) { newValue in if newValue { password = "" if additionalFieldValues["usePgpass"] == "true" { diff --git a/TablePro/Views/Connection/SSHProfileEditorView.swift b/TablePro/Views/Connection/SSHProfileEditorView.swift index 4c6b162c04..db8e772d14 100644 --- a/TablePro/Views/Connection/SSHProfileEditorView.swift +++ b/TablePro/Views/Connection/SSHProfileEditorView.swift @@ -96,18 +96,18 @@ struct SSHProfileEditorView: View { let entries = await Task.detached { SSHConfigParser.parse() }.value sshConfigEntries = entries } - .onChange(of: host) { _, _ in testSucceeded = false } - .onChange(of: port) { _, _ in testSucceeded = false } - .onChange(of: username) { _, _ in testSucceeded = false } - .onChange(of: authMethod) { _, _ in testSucceeded = false } - .onChange(of: sshPassword) { _, _ in testSucceeded = false } - .onChange(of: privateKeyPath) { _, _ in testSucceeded = false } - .onChange(of: keyPassphrase) { _, _ in testSucceeded = false } - .onChange(of: agentSocketOption) { _, _ in testSucceeded = false } - .onChange(of: customAgentSocketPath) { _, _ in testSucceeded = false } - .onChange(of: totpMode) { _, _ in testSucceeded = false } - .onChange(of: totpSecret) { _, _ in testSucceeded = false } - .onChange(of: jumpHosts) { _, _ in testSucceeded = false } + .onChange(of: host) { _ in testSucceeded = false } + .onChange(of: port) { _ in testSucceeded = false } + .onChange(of: username) { _ in testSucceeded = false } + .onChange(of: authMethod) { _ in testSucceeded = false } + .onChange(of: sshPassword) { _ in testSucceeded = false } + .onChange(of: privateKeyPath) { _ in testSucceeded = false } + .onChange(of: keyPassphrase) { _ in testSucceeded = false } + .onChange(of: agentSocketOption) { _ in testSucceeded = false } + .onChange(of: customAgentSocketPath) { _ in testSucceeded = false } + .onChange(of: totpMode) { _ in testSucceeded = false } + .onChange(of: totpSecret) { _ in testSucceeded = false } + .onChange(of: jumpHosts) { _ in testSucceeded = false } .onDisappear { testTask?.cancel() } @@ -124,7 +124,7 @@ struct SSHProfileEditorView: View { Text(entry.displayName).tag(entry.host) } } - .onChange(of: selectedSSHConfigHost) { + .onChange(of: selectedSSHConfigHost) { _ in applySSHConfigEntry(selectedSSHConfigHost) } } diff --git a/TablePro/Views/Connection/TrailingPaneUnavailableView.swift b/TablePro/Views/Connection/TrailingPaneUnavailableView.swift index e14e3b016f..a41daed41d 100644 --- a/TablePro/Views/Connection/TrailingPaneUnavailableView.swift +++ b/TablePro/Views/Connection/TrailingPaneUnavailableView.swift @@ -19,7 +19,7 @@ internal struct TrailingPaneUnavailableView: View { internal let surface: TrailingPaneSurface internal var body: some View { - ContentUnavailableView( + UnavailableStateView( String(localized: "Not Connected"), systemImage: "sidebar.right", description: Text(description) diff --git a/TablePro/Views/Connection/TypeChooser/DatabaseTypeChooserModel.swift b/TablePro/Views/Connection/TypeChooser/DatabaseTypeChooserModel.swift index 47ba40ea8a..29704e50b4 100644 --- a/TablePro/Views/Connection/TypeChooser/DatabaseTypeChooserModel.swift +++ b/TablePro/Views/Connection/TypeChooser/DatabaseTypeChooserModel.swift @@ -3,17 +3,16 @@ // TablePro // +import Combine import Foundation -import Observation @MainActor -@Observable -final class DatabaseTypeChooserModel { - var searchText: String = "" { +final class DatabaseTypeChooserModel: ObservableObject { + @Published var searchText: String = "" { didSet { settleHighlight() } } - var highlightedType: DatabaseType? + @Published var highlightedType: DatabaseType? private let allTypes: [DatabaseType] diff --git a/TablePro/Views/Connection/TypeChooser/DatabaseTypeChooserSheet.swift b/TablePro/Views/Connection/TypeChooser/DatabaseTypeChooserSheet.swift index 78533a0ca6..72efcd0c20 100644 --- a/TablePro/Views/Connection/TypeChooser/DatabaseTypeChooserSheet.swift +++ b/TablePro/Views/Connection/TypeChooser/DatabaseTypeChooserSheet.swift @@ -11,7 +11,7 @@ struct DatabaseTypeChooserSheet: View { let onImportFromURL: (() -> Void)? let onCancel: () -> Void - @State private var model = DatabaseTypeChooserModel() + @StateObject private var model = DatabaseTypeChooserModel() @Environment(\.dismiss) private var dismiss init( @@ -72,7 +72,7 @@ struct DatabaseTypeChooserSheet: View { @ViewBuilder private var content: some View { if model.groupedTypes.isEmpty { - ContentUnavailableView.search(text: model.searchText) + UnavailableStateView.search(text: model.searchText) .frame(maxWidth: .infinity, maxHeight: .infinity) } else { ScrollViewReader { proxy in @@ -105,7 +105,7 @@ struct DatabaseTypeChooserSheet: View { proxy.scrollTo(initialType, anchor: .center) } } - .onChange(of: model.highlightedType) { _, highlighted in + .onChange(of: model.highlightedType) { highlighted in guard let highlighted else { return } proxy.scrollTo(highlighted) } diff --git a/TablePro/Views/ConnectionForm/Components/ConnectionFormActionBar.swift b/TablePro/Views/ConnectionForm/Components/ConnectionFormActionBar.swift index 0c5248ba27..9d245be067 100644 --- a/TablePro/Views/ConnectionForm/Components/ConnectionFormActionBar.swift +++ b/TablePro/Views/ConnectionForm/Components/ConnectionFormActionBar.swift @@ -12,7 +12,7 @@ import SwiftUI /// the titlebar, and it leaves the leading edge for the status the buttons depend on. That pairing /// is the point here: a disabled Save is only actionable next to the field it is waiting for. struct ConnectionFormActionBar: View { - @Bindable var coordinator: ConnectionFormCoordinator + @ObservedObject var coordinator: ConnectionFormCoordinator /// Walked once per body evaluation and read four times from it. /// diff --git a/TablePro/Views/ConnectionForm/Components/CredentialProfilePicker.swift b/TablePro/Views/ConnectionForm/Components/CredentialProfilePicker.swift index be68066575..85f26fa138 100644 --- a/TablePro/Views/ConnectionForm/Components/CredentialProfilePicker.swift +++ b/TablePro/Views/ConnectionForm/Components/CredentialProfilePicker.swift @@ -11,7 +11,7 @@ import SwiftUI /// what a pop-up button is for. Managing the profiles is a separate surface rather than another /// sheet on this window, so the same list serves every connection. struct CredentialProfilePicker: View { - @Bindable var auth: AuthPaneViewModel + @ObservedObject var auth: AuthPaneViewModel var body: some View { Picker(String(localized: "Credentials"), selection: $auth.credentialMode) { diff --git a/TablePro/Views/ConnectionForm/Components/PluginInstallStatusRow.swift b/TablePro/Views/ConnectionForm/Components/PluginInstallStatusRow.swift index 4b1a7a6ec2..bf2793848b 100644 --- a/TablePro/Views/ConnectionForm/Components/PluginInstallStatusRow.swift +++ b/TablePro/Views/ConnectionForm/Components/PluginInstallStatusRow.swift @@ -6,9 +6,9 @@ import SwiftUI struct PluginInstallStatusRow: View { - @Bindable var coordinator: ConnectionFormCoordinator + @ObservedObject var coordinator: ConnectionFormCoordinator - private var tracker: PluginInstallTracker { PluginInstallTracker.shared } + @ObservedObject private var tracker = PluginInstallTracker.shared var body: some View { LabeledContent(String(localized: "Plugin")) { diff --git a/TablePro/Views/ConnectionForm/Components/SelectionAwareForeground.swift b/TablePro/Views/ConnectionForm/Components/SelectionAwareForeground.swift index c1d27cbf71..1b2dd060c4 100644 --- a/TablePro/Views/ConnectionForm/Components/SelectionAwareForeground.swift +++ b/TablePro/Views/ConnectionForm/Components/SelectionAwareForeground.swift @@ -5,6 +5,7 @@ import SwiftUI +@available(macOS 14.0, *) private struct SelectionAwareForeground: ViewModifier { let standard: Color @Environment(\.backgroundProminence) private var backgroundProminence @@ -17,7 +18,13 @@ private struct SelectionAwareForeground: ViewModifier { } extension View { + /// `backgroundProminence` is macOS 14; before it the standard colour is the only answer. + @ViewBuilder func selectionAwareForeground(_ standard: Color) -> some View { - modifier(SelectionAwareForeground(standard: standard)) + if #available(macOS 14.0, *) { + modifier(SelectionAwareForeground(standard: standard)) + } else { + foregroundStyle(standard) + } } } diff --git a/TablePro/Views/ConnectionForm/Components/TestConnectionStatusButton.swift b/TablePro/Views/ConnectionForm/Components/TestConnectionStatusButton.swift index 9eed2fc796..44ad7e1100 100644 --- a/TablePro/Views/ConnectionForm/Components/TestConnectionStatusButton.swift +++ b/TablePro/Views/ConnectionForm/Components/TestConnectionStatusButton.swift @@ -6,7 +6,7 @@ import SwiftUI struct TestConnectionStatusButton: View { - @Bindable var coordinator: ConnectionFormCoordinator + @ObservedObject var coordinator: ConnectionFormCoordinator var body: some View { Button { diff --git a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift index 4cc7379ddd..561a84ff93 100644 --- a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift +++ b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift @@ -19,52 +19,51 @@ final class WeakCoordinatorRef { } } -@Observable @MainActor -final class ConnectionFormCoordinator { +final class ConnectionFormCoordinator: ObservableObject { nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "ConnectionFormCoordinator") let connectionId: UUID? - private(set) var originalConnection: DatabaseConnection? + @Published private(set) var originalConnection: DatabaseConnection? - var network: NetworkPaneViewModel - var auth: AuthPaneViewModel - var ssh: SSHPaneViewModel - var remoteFile: RemoteFilePaneViewModel - var cloudflareTunnel: CloudflareTunnelPaneViewModel - var cloudSQLProxy: CloudSQLProxyPaneViewModel - var socksProxy: SOCKSProxyPaneViewModel - var tunnelCommand: TunnelCommandPaneViewModel - var ssl: SSLPaneViewModel - var customization: CustomizationPaneViewModel - var advanced: AdvancedPaneViewModel - var aiRules: AIRulesPaneViewModel + @Published var network: NetworkPaneViewModel + @Published var auth: AuthPaneViewModel + @Published var ssh: SSHPaneViewModel + @Published var remoteFile: RemoteFilePaneViewModel + @Published var cloudflareTunnel: CloudflareTunnelPaneViewModel + @Published var cloudSQLProxy: CloudSQLProxyPaneViewModel + @Published var socksProxy: SOCKSProxyPaneViewModel + @Published var tunnelCommand: TunnelCommandPaneViewModel + @Published var ssl: SSLPaneViewModel + @Published var customization: CustomizationPaneViewModel + @Published var advanced: AdvancedPaneViewModel + @Published var aiRules: AIRulesPaneViewModel - var selectedTab: ConnectionFormTab = .general - var hasLoadedData: Bool = false + @Published var selectedTab: ConnectionFormTab = .general + @Published var hasLoadedData: Bool = false - var isTesting: Bool = false - var testSucceeded: Bool = false - var testTask: Task? + @Published var isTesting: Bool = false + @Published var testSucceeded: Bool = false + @Published var testTask: Task? - var isInstallingPlugin: Bool = false - var pluginInstallError: String? - var pluginInstallConnection: DatabaseConnection? - var pluginDiagnostic: PluginDiagnosticItem? + @Published var isInstallingPlugin: Bool = false + @Published var pluginInstallError: String? + @Published var pluginInstallConnection: DatabaseConnection? + @Published var pluginDiagnostic: PluginDiagnosticItem? - var saveError: String? + @Published var saveError: String? - var clipboardCandidate: ParsedConnection? - var clipboardBannerDismissed: Bool = false + @Published var clipboardCandidate: ParsedConnection? + @Published var clipboardBannerDismissed: Bool = false - var isChoosingType: Bool = false + @Published var isChoosingType: Bool = false - private var temporaryTestIds: Set = [] + @Published private var temporaryTestIds: Set = [] - @ObservationIgnored let services: AppServices + let services: AppServices var storage: ConnectionStorage { services.connectionStorage } - var dismissAction: (() -> Void)? + @Published var dismissAction: (() -> Void)? var isNew: Bool { connectionId == nil } diff --git a/TablePro/Views/ConnectionForm/ConnectionFormDetailView.swift b/TablePro/Views/ConnectionForm/ConnectionFormDetailView.swift index e40d78ba35..fd468e0ff8 100644 --- a/TablePro/Views/ConnectionForm/ConnectionFormDetailView.swift +++ b/TablePro/Views/ConnectionForm/ConnectionFormDetailView.swift @@ -12,7 +12,7 @@ import SwiftUI /// `.sidebarTrackingSeparator` to resolve. Wrapping the split view to span a bar across both /// columns would take that away, and the sidebar has nothing to commit anyway. struct ConnectionFormDetailView: View { - @Bindable var coordinator: ConnectionFormCoordinator + @ObservedObject var coordinator: ConnectionFormCoordinator var body: some View { VStack(spacing: 0) { diff --git a/TablePro/Views/ConnectionForm/ConnectionFormSplitViewController.swift b/TablePro/Views/ConnectionForm/ConnectionFormSplitViewController.swift index 8d8a5d86d6..336d403317 100644 --- a/TablePro/Views/ConnectionForm/ConnectionFormSplitViewController.swift +++ b/TablePro/Views/ConnectionForm/ConnectionFormSplitViewController.swift @@ -9,6 +9,7 @@ // import AppKit +import Combine import Observation import SwiftUI @@ -20,6 +21,8 @@ internal final class ConnectionFormSplitViewController: NSSplitViewController { private let coordinator: ConnectionFormCoordinator + private var titleObservations: [AnyCancellable] = [] + internal init(coordinator: ConnectionFormCoordinator) { self.coordinator = coordinator super.init(nibName: nil, bundle: nil) @@ -63,11 +66,17 @@ internal final class ConnectionFormSplitViewController: NSSplitViewController { /// writes `window.title` directly. `withObservationTracking` fires once per change, so the /// closure re-arms itself. private func trackTitle() { - withObservationTracking { - title = windowTitle - } onChange: { [weak self] in - Task { @MainActor in self?.trackTitle() } - } + title = windowTitle + titleObservations = [ + coordinator.onMainActorChange { [weak self] in + guard let self else { return } + self.title = self.windowTitle + }, + coordinator.network.onMainActorChange { [weak self] in + guard let self else { return } + self.title = self.windowTitle + }, + ] } private var windowTitle: String { diff --git a/TablePro/Views/ConnectionForm/Panes/AppearancePaneView.swift b/TablePro/Views/ConnectionForm/Panes/AppearancePaneView.swift index b33af7f870..f03d6e3a94 100644 --- a/TablePro/Views/ConnectionForm/Panes/AppearancePaneView.swift +++ b/TablePro/Views/ConnectionForm/Panes/AppearancePaneView.swift @@ -7,7 +7,7 @@ import SwiftUI /// How this connection is recognised in the connection list and the window chrome. struct AppearancePaneView: View { - @Bindable var coordinator: ConnectionFormCoordinator + @ObservedObject var coordinator: ConnectionFormCoordinator var body: some View { Form { diff --git a/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift b/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift index 2a2f85b359..7e75978086 100644 --- a/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift +++ b/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift @@ -13,7 +13,7 @@ import UniformTypeIdentifiers /// Everything about how the bytes get there belongs to `NetworkPaneView`, so a connection that /// needs no tunnel and no TLS never sees a control about either. struct GeneralPaneView: View { - @Bindable var coordinator: ConnectionFormCoordinator + @ObservedObject var coordinator: ConnectionFormCoordinator @FocusState private var nameFocused: Bool private var type: DatabaseType { coordinator.network.type } diff --git a/TablePro/Views/ConnectionForm/Panes/NetworkPaneView.swift b/TablePro/Views/ConnectionForm/Panes/NetworkPaneView.swift index 8381cb23e6..06c3d730f9 100644 --- a/TablePro/Views/ConnectionForm/Panes/NetworkPaneView.swift +++ b/TablePro/Views/ConnectionForm/Panes/NetworkPaneView.swift @@ -13,7 +13,7 @@ import TableProPluginKit /// in a state `DatabaseConnection.activeTunnelKind` reports as no transport at all. One picker /// makes that state unrepresentable. struct NetworkPaneView: View { - @Bindable var coordinator: ConnectionFormCoordinator + @ObservedObject var coordinator: ConnectionFormCoordinator var body: some View { Form { diff --git a/TablePro/Views/ConnectionForm/Panes/OptionsPaneView.swift b/TablePro/Views/ConnectionForm/Panes/OptionsPaneView.swift index 8bd7ff34c7..606bc7b977 100644 --- a/TablePro/Views/ConnectionForm/Panes/OptionsPaneView.swift +++ b/TablePro/Views/ConnectionForm/Panes/OptionsPaneView.swift @@ -13,7 +13,7 @@ import TableProPluginKit /// three sidebar panes called Customization, Advanced and AI Rules. They answer one question, so /// they are one tab. struct OptionsPaneView: View { - @Bindable var coordinator: ConnectionFormCoordinator + @ObservedObject var coordinator: ConnectionFormCoordinator private var databaseType: DatabaseType { coordinator.network.type } private var aiIsEnabled: Bool { AppSettingsManager.shared.ai.enabled } diff --git a/TablePro/Views/ConnectionForm/Panes/Transports/CloudSQLProxyTransportSections.swift b/TablePro/Views/ConnectionForm/Panes/Transports/CloudSQLProxyTransportSections.swift index 36e858e57a..15ea644d3a 100644 --- a/TablePro/Views/ConnectionForm/Panes/Transports/CloudSQLProxyTransportSections.swift +++ b/TablePro/Views/ConnectionForm/Panes/Transports/CloudSQLProxyTransportSections.swift @@ -7,7 +7,7 @@ import AppKit import SwiftUI struct CloudSQLProxyTransportSections: View { - @Bindable var coordinator: ConnectionFormCoordinator + @ObservedObject var coordinator: ConnectionFormCoordinator private var viewModel: CloudSQLProxyPaneViewModel { coordinator.cloudSQLProxy } diff --git a/TablePro/Views/ConnectionForm/Panes/Transports/CloudflareTransportSections.swift b/TablePro/Views/ConnectionForm/Panes/Transports/CloudflareTransportSections.swift index 5d41398de6..20e6054148 100644 --- a/TablePro/Views/ConnectionForm/Panes/Transports/CloudflareTransportSections.swift +++ b/TablePro/Views/ConnectionForm/Panes/Transports/CloudflareTransportSections.swift @@ -7,7 +7,7 @@ import AppKit import SwiftUI struct CloudflareTransportSections: View { - @Bindable var coordinator: ConnectionFormCoordinator + @ObservedObject var coordinator: ConnectionFormCoordinator private var viewModel: CloudflareTunnelPaneViewModel { coordinator.cloudflareTunnel } diff --git a/TablePro/Views/ConnectionForm/Panes/Transports/SOCKSProxyTransportSections.swift b/TablePro/Views/ConnectionForm/Panes/Transports/SOCKSProxyTransportSections.swift index ca2358c692..f6dd941c71 100644 --- a/TablePro/Views/ConnectionForm/Panes/Transports/SOCKSProxyTransportSections.swift +++ b/TablePro/Views/ConnectionForm/Panes/Transports/SOCKSProxyTransportSections.swift @@ -6,7 +6,7 @@ import SwiftUI struct SOCKSProxyTransportSections: View { - @Bindable var coordinator: ConnectionFormCoordinator + @ObservedObject var coordinator: ConnectionFormCoordinator var body: some View { serverSection diff --git a/TablePro/Views/ConnectionForm/Panes/Transports/SSHServerSections.swift b/TablePro/Views/ConnectionForm/Panes/Transports/SSHServerSections.swift index 40405853bc..5845358c3b 100644 --- a/TablePro/Views/ConnectionForm/Panes/Transports/SSHServerSections.swift +++ b/TablePro/Views/ConnectionForm/Panes/Transports/SSHServerSections.swift @@ -148,7 +148,7 @@ struct SSHServerSections: View { Text(entry.displayName).tag(entry.host) } } - .onChange(of: sshState.selectedConfigHost) { + .onChange(of: sshState.selectedConfigHost) { _ in applySSHConfigEntry(sshState.selectedConfigHost) } } diff --git a/TablePro/Views/ConnectionForm/Panes/Transports/SSHTransportSections.swift b/TablePro/Views/ConnectionForm/Panes/Transports/SSHTransportSections.swift index c3e7b6db4b..701fdd6860 100644 --- a/TablePro/Views/ConnectionForm/Panes/Transports/SSHTransportSections.swift +++ b/TablePro/Views/ConnectionForm/Panes/Transports/SSHTransportSections.swift @@ -12,7 +12,7 @@ import SwiftUI /// selected it, which is why the old `Toggle("Enable SSH Tunnel")` that made up an entire pane on /// its own is gone. struct SSHTransportSections: View { - @Bindable var coordinator: ConnectionFormCoordinator + @ObservedObject var coordinator: ConnectionFormCoordinator var body: some View { SSHServerSections(sshState: $coordinator.ssh.state) @@ -96,7 +96,7 @@ struct SSHTransportSections: View { /// The server half is the same problem whether what comes back is a socket or a file, so it is the /// same view. What this adds is the path. struct RemoteFileTransportSections: View { - @Bindable var coordinator: ConnectionFormCoordinator + @ObservedObject var coordinator: ConnectionFormCoordinator var body: some View { Section { diff --git a/TablePro/Views/ConnectionForm/Panes/Transports/TunnelCommandTransportSections.swift b/TablePro/Views/ConnectionForm/Panes/Transports/TunnelCommandTransportSections.swift index 0e1a9a0d11..4e14a63fea 100644 --- a/TablePro/Views/ConnectionForm/Panes/Transports/TunnelCommandTransportSections.swift +++ b/TablePro/Views/ConnectionForm/Panes/Transports/TunnelCommandTransportSections.swift @@ -6,7 +6,7 @@ import SwiftUI struct TunnelCommandTransportSections: View { - @Bindable var coordinator: ConnectionFormCoordinator + @ObservedObject var coordinator: ConnectionFormCoordinator private var viewModel: TunnelCommandPaneViewModel { coordinator.tunnelCommand } diff --git a/TablePro/Views/ConnectionForm/Sidebar/ConnectionFormSidebar.swift b/TablePro/Views/ConnectionForm/Sidebar/ConnectionFormSidebar.swift index e5fca341a2..8ff4aa3535 100644 --- a/TablePro/Views/ConnectionForm/Sidebar/ConnectionFormSidebar.swift +++ b/TablePro/Views/ConnectionForm/Sidebar/ConnectionFormSidebar.swift @@ -16,7 +16,7 @@ import SwiftUI /// whole time; `ConnectionFormTab.validationIssues(for:)` returns them, and the action bar spells /// out the first one. struct ConnectionFormSidebar: View { - @Bindable var coordinator: ConnectionFormCoordinator + @ObservedObject var coordinator: ConnectionFormCoordinator var body: some View { List(selection: $coordinator.selectedTab) { diff --git a/TablePro/Views/ConnectionForm/ViewModels/AIRulesPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/AIRulesPaneViewModel.swift index 47c67b7676..acad00c3e8 100644 --- a/TablePro/Views/ConnectionForm/ViewModels/AIRulesPaneViewModel.swift +++ b/TablePro/Views/ConnectionForm/ViewModels/AIRulesPaneViewModel.swift @@ -3,14 +3,14 @@ // TablePro // +import Combine import Foundation -@Observable @MainActor -final class AIRulesPaneViewModel { - var rules: String = "" +final class AIRulesPaneViewModel: ObservableObject { + @Published var rules: String = "" - var coordinator: WeakCoordinatorRef? + @Published var coordinator: WeakCoordinatorRef? func load(from connection: DatabaseConnection) { rules = connection.aiRules ?? "" diff --git a/TablePro/Views/ConnectionForm/ViewModels/AdvancedPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/AdvancedPaneViewModel.swift index 9e13c94a50..5b16902cf2 100644 --- a/TablePro/Views/ConnectionForm/ViewModels/AdvancedPaneViewModel.swift +++ b/TablePro/Views/ConnectionForm/ViewModels/AdvancedPaneViewModel.swift @@ -3,20 +3,20 @@ // TablePro // +import Combine import Foundation import TableProPluginKit -@Observable @MainActor -final class AdvancedPaneViewModel { - var additionalFieldValues: [String: String] = [:] - var startupCommands: String = "" - var preConnectScript: String = "" - var externalAccess: ExternalAccessLevel = .readOnly - var localOnly: Bool = false - var aiPolicy: AIConnectionPolicy? +final class AdvancedPaneViewModel: ObservableObject { + @Published var additionalFieldValues: [String: String] = [:] + @Published var startupCommands: String = "" + @Published var preConnectScript: String = "" + @Published var externalAccess: ExternalAccessLevel = .readOnly + @Published var localOnly: Bool = false + @Published var aiPolicy: AIConnectionPolicy? - var coordinator: WeakCoordinatorRef? + @Published var coordinator: WeakCoordinatorRef? var advancedFields: [ConnectionField] { guard let type = coordinator?.value?.network.type else { return [] } diff --git a/TablePro/Views/ConnectionForm/ViewModels/AuthPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/AuthPaneViewModel.swift index 2e5ddc153d..9d4ebf7c26 100644 --- a/TablePro/Views/ConnectionForm/ViewModels/AuthPaneViewModel.swift +++ b/TablePro/Views/ConnectionForm/ViewModels/AuthPaneViewModel.swift @@ -3,6 +3,7 @@ // TablePro // +import Combine import Foundation import TableProPluginKit @@ -23,25 +24,24 @@ enum PgpassStatus { } } -@Observable @MainActor -final class AuthPaneViewModel { - var username: String = "" - var password: String = "" - var promptForPassword: Bool = false - var additionalFieldValues: [String: String] = [:] - var pgpassStatus: PgpassStatus = .notChecked +final class AuthPaneViewModel: ObservableObject { + @Published var username: String = "" + @Published var password: String = "" + @Published var promptForPassword: Bool = false + @Published var additionalFieldValues: [String: String] = [:] + @Published var pgpassStatus: PgpassStatus = .notChecked /// Which credentials this connection signs in with: its own, or a named profile shared with /// every other connection pointing at the same one. - var credentialMode: CredentialMode = .inline - var credentialProfiles: [CredentialProfile] = [] + @Published var credentialMode: CredentialMode = .inline + @Published var credentialProfiles: [CredentialProfile] = [] /// What the keychain held when the form opened. An empty password field means the user cleared /// it only if there was something to clear and the read succeeded. - private(set) var storedPasswordState: ConnectionStorage.StoredSecretState = .absent + @Published private(set) var storedPasswordState: ConnectionStorage.StoredSecretState = .absent - var coordinator: WeakCoordinatorRef? + @Published var coordinator: WeakCoordinatorRef? var authFields: [ConnectionField] { guard let type = coordinator?.value?.network.type else { return [] } @@ -106,7 +106,7 @@ final class AuthPaneViewModel { usesCredentialProfile && selectedCredentialProfile == nil } - var isSavingCredentialsAsProfile = false + @Published var isSavingCredentialsAsProfile = false func loadCredentialProfiles() { credentialProfiles = CredentialProfileStorage.shared.loadProfiles() diff --git a/TablePro/Views/ConnectionForm/ViewModels/CloudSQLProxyPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/CloudSQLProxyPaneViewModel.swift index a4862e01e4..1b9f9ffa0f 100644 --- a/TablePro/Views/ConnectionForm/ViewModels/CloudSQLProxyPaneViewModel.swift +++ b/TablePro/Views/ConnectionForm/ViewModels/CloudSQLProxyPaneViewModel.swift @@ -3,23 +3,23 @@ // TablePro // +import Combine import Foundation import os -@Observable @MainActor -final class CloudSQLProxyPaneViewModel { +final class CloudSQLProxyPaneViewModel: ObservableObject { nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "CloudSQLProxyPane") - var state = CloudSQLProxyFormState() + @Published var state = CloudSQLProxyFormState() - var coordinator: WeakCoordinatorRef? + @Published var coordinator: WeakCoordinatorRef? - var resolvedBinaryPath: String? - var didResolveBinary: Bool = false - var downloadedVersion: String? - var isDownloading: Bool = false - var downloadError: String? + @Published var resolvedBinaryPath: String? + @Published var didResolveBinary: Bool = false + @Published var downloadedVersion: String? + @Published var isDownloading: Bool = false + @Published var downloadError: String? var validationIssues: [String] { guard state.enabled else { return [] } diff --git a/TablePro/Views/ConnectionForm/ViewModels/CloudflareTunnelPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/CloudflareTunnelPaneViewModel.swift index f26409926b..f96cf51a63 100644 --- a/TablePro/Views/ConnectionForm/ViewModels/CloudflareTunnelPaneViewModel.swift +++ b/TablePro/Views/ConnectionForm/ViewModels/CloudflareTunnelPaneViewModel.swift @@ -3,23 +3,23 @@ // TablePro // +import Combine import Foundation import os -@Observable @MainActor -final class CloudflareTunnelPaneViewModel { +final class CloudflareTunnelPaneViewModel: ObservableObject { nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "CloudflareTunnelPane") - var state = CloudflareTunnelFormState() + @Published var state = CloudflareTunnelFormState() - var coordinator: WeakCoordinatorRef? + @Published var coordinator: WeakCoordinatorRef? - var resolvedBinaryPath: String? - var didResolveBinary: Bool = false - var signInError: String? + @Published var resolvedBinaryPath: String? + @Published var didResolveBinary: Bool = false + @Published var signInError: String? - @ObservationIgnored private var loginProcess: Process? + private var loginProcess: Process? var validationIssues: [String] { guard state.enabled else { return [] } diff --git a/TablePro/Views/ConnectionForm/ViewModels/CustomizationPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/CustomizationPaneViewModel.swift index 0c42eab4e4..e2940cca60 100644 --- a/TablePro/Views/ConnectionForm/ViewModels/CustomizationPaneViewModel.swift +++ b/TablePro/Views/ConnectionForm/ViewModels/CustomizationPaneViewModel.swift @@ -3,17 +3,17 @@ // TablePro // +import Combine import Foundation -@Observable @MainActor -final class CustomizationPaneViewModel { - var color: ConnectionColor = .none - var tagIds: [UUID] = [] - var groupId: UUID? - var safeModeLevel: SafeModeLevel = .silent +final class CustomizationPaneViewModel: ObservableObject { + @Published var color: ConnectionColor = .none + @Published var tagIds: [UUID] = [] + @Published var groupId: UUID? + @Published var safeModeLevel: SafeModeLevel = .silent - var coordinator: WeakCoordinatorRef? + @Published var coordinator: WeakCoordinatorRef? var validationIssues: [String] { [] } diff --git a/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel.swift index 3f60beabf5..887dc08309 100644 --- a/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel.swift +++ b/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel.swift @@ -3,22 +3,22 @@ // TablePro // +import Combine import Foundation import Network import TableProPluginKit -@Observable @MainActor -final class NetworkPaneViewModel { - var name: String = "" - var type: DatabaseType = .mysql - var host: String = "" - var port: String = "" - var database: String = "" - var sshForwardUnixSocketPath: String = "" - var additionalFieldValues: [String: String] = [:] - - var coordinator: WeakCoordinatorRef? +final class NetworkPaneViewModel: ObservableObject { + @Published var name: String = "" + @Published var type: DatabaseType = .mysql + @Published var host: String = "" + @Published var port: String = "" + @Published var database: String = "" + @Published var sshForwardUnixSocketPath: String = "" + @Published var additionalFieldValues: [String: String] = [:] + + @Published var coordinator: WeakCoordinatorRef? var forwardsToUnixSocket: Bool { !sshForwardUnixSocketPath.trimmingCharacters(in: .whitespaces).isEmpty diff --git a/TablePro/Views/ConnectionForm/ViewModels/RemoteFilePaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/RemoteFilePaneViewModel.swift index e013e41e0f..d6b50b51ef 100644 --- a/TablePro/Views/ConnectionForm/ViewModels/RemoteFilePaneViewModel.swift +++ b/TablePro/Views/ConnectionForm/ViewModels/RemoteFilePaneViewModel.swift @@ -3,6 +3,7 @@ // TablePro // +import Combine import Foundation /// Validation for the Remote File pane. @@ -11,10 +12,9 @@ import Foundation /// connection reaches its server with the same credentials a tunnel would. Only two fields are its /// own, and only one of them can be wrong: a connection that names a server and no file has nothing /// to open. -@Observable @MainActor -final class RemoteFilePaneViewModel { - var coordinator: WeakCoordinatorRef? +final class RemoteFilePaneViewModel: ObservableObject { + @Published var coordinator: WeakCoordinatorRef? /// Nothing to say unless this pane is the one on screen. /// diff --git a/TablePro/Views/ConnectionForm/ViewModels/SOCKSProxyPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/SOCKSProxyPaneViewModel.swift index b4f71441db..410e0c2e52 100644 --- a/TablePro/Views/ConnectionForm/ViewModels/SOCKSProxyPaneViewModel.swift +++ b/TablePro/Views/ConnectionForm/ViewModels/SOCKSProxyPaneViewModel.swift @@ -3,14 +3,14 @@ // TablePro // +import Combine import Foundation -@Observable @MainActor -final class SOCKSProxyPaneViewModel { - var state = SOCKSProxyFormState() +final class SOCKSProxyPaneViewModel: ObservableObject { + @Published var state = SOCKSProxyFormState() - var coordinator: WeakCoordinatorRef? + @Published var coordinator: WeakCoordinatorRef? var validationIssues: [String] { guard state.enabled else { return [] } diff --git a/TablePro/Views/ConnectionForm/ViewModels/SSHPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/SSHPaneViewModel.swift index caf23a66bf..30015b471d 100644 --- a/TablePro/Views/ConnectionForm/ViewModels/SSHPaneViewModel.swift +++ b/TablePro/Views/ConnectionForm/ViewModels/SSHPaneViewModel.swift @@ -3,14 +3,14 @@ // TablePro // +import Combine import Foundation -@Observable @MainActor -final class SSHPaneViewModel { - var state = SSHTunnelFormState() +final class SSHPaneViewModel: ObservableObject { + @Published var state = SSHTunnelFormState() - var coordinator: WeakCoordinatorRef? + @Published var coordinator: WeakCoordinatorRef? var validationIssues: [String] { guard state.enabled else { return [] } diff --git a/TablePro/Views/ConnectionForm/ViewModels/SSLPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/SSLPaneViewModel.swift index 7a856eef71..294c3b5046 100644 --- a/TablePro/Views/ConnectionForm/ViewModels/SSLPaneViewModel.swift +++ b/TablePro/Views/ConnectionForm/ViewModels/SSLPaneViewModel.swift @@ -3,19 +3,19 @@ // TablePro // +import Combine import Foundation import TableProPluginKit -@Observable @MainActor -final class SSLPaneViewModel { - var mode: SSLMode = .disabled - var caCertPath: String = "" - var clientCertPath: String = "" - var clientKeyPath: String = "" - var clientKeyPassphrase: String = "" +final class SSLPaneViewModel: ObservableObject { + @Published var mode: SSLMode = .disabled + @Published var caCertPath: String = "" + @Published var clientCertPath: String = "" + @Published var clientKeyPath: String = "" + @Published var clientKeyPassphrase: String = "" - var coordinator: WeakCoordinatorRef? + @Published var coordinator: WeakCoordinatorRef? /// Silent on a driver that renders no SSL section, so a stored mode the form cannot show /// cannot disable Save over a certificate field the user has no way to reach. diff --git a/TablePro/Views/ConnectionForm/ViewModels/TunnelCommandPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/TunnelCommandPaneViewModel.swift index 51a533b9f5..2b37cbeb35 100644 --- a/TablePro/Views/ConnectionForm/ViewModels/TunnelCommandPaneViewModel.swift +++ b/TablePro/Views/ConnectionForm/ViewModels/TunnelCommandPaneViewModel.swift @@ -3,14 +3,14 @@ // TablePro // +import Combine import Foundation -@Observable @MainActor -final class TunnelCommandPaneViewModel { - var state = TunnelCommandFormState() +final class TunnelCommandPaneViewModel: ObservableObject { + @Published var state = TunnelCommandFormState() - var coordinator: WeakCoordinatorRef? + @Published var coordinator: WeakCoordinatorRef? var validationIssues: [String] { guard state.enabled else { return [] } diff --git a/TablePro/Views/DatabaseSwitcher/CreateDatabaseSheet.swift b/TablePro/Views/DatabaseSwitcher/CreateDatabaseSheet.swift index f67c1a5ced..ccf8bed4e7 100644 --- a/TablePro/Views/DatabaseSwitcher/CreateDatabaseSheet.swift +++ b/TablePro/Views/DatabaseSwitcher/CreateDatabaseSheet.swift @@ -4,7 +4,7 @@ struct CreateDatabaseSheet: View { @Environment(\.dismiss) private var dismiss let databaseType: DatabaseType - let viewModel: DatabaseSwitcherViewModel + @ObservedObject var viewModel: DatabaseSwitcherViewModel var onCreated: ((String) -> Void)? @State private var loadState: LoadState = .loading diff --git a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift index b0bb436f59..4d46e085fd 100644 --- a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift +++ b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift @@ -74,7 +74,7 @@ struct DatabaseSwitcherPopover: View { /// An explicit closure rather than `@Environment(\.dismiss)`: the presenter owns the surface, /// and this content is hosted in an AppKit popover or panel that SwiftUI cannot dismiss. let dismiss: () -> Void - @State private var viewModel: DatabaseSwitcherViewModel + @StateObject private var viewModel: DatabaseSwitcherViewModel @State private var supportsCreateDatabase = false @State private var favoriteDatabases: Set = [] @@ -136,7 +136,7 @@ struct DatabaseSwitcherPopover: View { self.onRequestEdit = onRequestEdit self.schemaEditEligibility = schemaEditEligibility self.dismiss = dismiss - self._viewModel = State( + self._viewModel = StateObject( wrappedValue: DatabaseSwitcherViewModel( connectionId: connectionId, currentDatabase: currentDatabase, diff --git a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift index 627988876f..36cd06919b 100644 --- a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift +++ b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift @@ -19,7 +19,7 @@ struct DatabaseSwitcherSheet: View { let connectionId: UUID let onSelect: (String) -> Void - @State private var viewModel: DatabaseSwitcherViewModel + @StateObject private var viewModel: DatabaseSwitcherViewModel private enum FocusField { case list } @@ -39,7 +39,7 @@ struct DatabaseSwitcherSheet: View { self.databaseType = databaseType self.connectionId = connectionId self.onSelect = onSelect - self._viewModel = State( + self._viewModel = StateObject( wrappedValue: DatabaseSwitcherViewModel( connectionId: connectionId, currentDatabase: currentDatabase, @@ -136,16 +136,13 @@ struct DatabaseSwitcherSheet: View { .listStyle(.inset) .scrollContentBackground(.hidden) .focused($focus, equals: .list) - .onChange(of: viewModel.selectedDatabase) { _, newValue in + .onChange(of: viewModel.selectedDatabase) { newValue in guard let item = newValue else { return } withMotion(.easeInOut(duration: 0.15)) { proxy.scrollTo(item, anchor: .center) } } - .onKeyPress(.return) { - commitSelection() - return .handled - } + .modifier(ReturnKeyCommit(action: commitSelection)) } } @@ -244,3 +241,21 @@ struct DatabaseSwitcherSheet: View { connectionId: UUID() ) { _ in } } + +/// `onKeyPress` is macOS 14. On 13 the sheet's default button still commits, so Return keeps +/// working; what the handler adds is committing while focus sits in the list. +private struct ReturnKeyCommit: ViewModifier { + let action: () -> Void + + @ViewBuilder + func body(content: Content) -> some View { + if #available(macOS 14.0, *) { + content.onKeyPress(.return) { + action() + return .handled + } + } else { + content + } + } +} diff --git a/TablePro/Views/DatabaseSwitcher/SchemaEditorSheet.swift b/TablePro/Views/DatabaseSwitcher/SchemaEditorSheet.swift index fcd8939194..7cd2f5b8d4 100644 --- a/TablePro/Views/DatabaseSwitcher/SchemaEditorSheet.swift +++ b/TablePro/Views/DatabaseSwitcher/SchemaEditorSheet.swift @@ -20,7 +20,7 @@ import TableProPluginKit struct SchemaEditorSheet: View { @Environment(\.dismiss) private var dismiss - @State var model: SchemaEditorViewModel + @StateObject var model: SchemaEditorViewModel var onCompleted: ((String) -> Void)? private var entityName: String { @@ -243,7 +243,7 @@ struct SchemaEditorSheet: View { Label(String(localized: "Add Role"), systemImage: "plus") } .menuStyle(.button) - .buttonStyle(.accessoryBar) + .accessoryBarStyle() .fixedSize() .disabled(availableGrantees.isEmpty) } diff --git a/TablePro/Views/DatabaseSwitcher/SchemaPrivilegeTable.swift b/TablePro/Views/DatabaseSwitcher/SchemaPrivilegeTable.swift index f86a356a7b..decbb72d4c 100644 --- a/TablePro/Views/DatabaseSwitcher/SchemaPrivilegeTable.swift +++ b/TablePro/Views/DatabaseSwitcher/SchemaPrivilegeTable.swift @@ -12,37 +12,106 @@ import TableProPluginKit /// every object. Checkboxes rather than switches, which is what the HIG asks for a grid of /// independent on-off settings. /// -/// A real `Table`, so the column headings, the row striping and the metrics are AppKit's rather -/// than hand-drawn. The privilege set is the engine's, so the columns come from -/// `TableColumnForEach`; that is what puts the app's floor at macOS 14.4. +/// The privilege set is the engine's, so the columns are built from it rather than written out. +/// `TableColumnForEach` is the native way to say that and it is macOS 14.4, so macOS 13 gets a +/// `Grid` laid out to the same shape: same order, same cells, same accessibility, drawn rather +/// than measured by AppKit. Both arms read `cell(_:row:)`, so a change to what a cell offers +/// lands in both at once and only the surrounding metrics differ. struct SchemaPrivilegeTable: View { - let model: SchemaEditorViewModel + @ObservedObject var model: SchemaEditorViewModel var body: some View { + content + .accessibilityIdentifier("schema-privilege-table") + } + + @ViewBuilder + private var content: some View { + if #available(macOS 14.4, *) { + nativeTable + } else { + gridFallback + } + } + + /// A real `Table`, so the column headings, the row striping and the metrics are AppKit's + /// rather than hand-drawn. + @available(macOS 14.4, *) + private var nativeTable: some View { Table(model.granteeRows) { TableColumn(String(localized: "Role")) { row in - Text(row.displayName) - .lineLimit(1) - .truncationMode(.middle) - .help(row.locked.isEmpty ? "" : String(localized: "Granted by another role")) + roleLabel(row) } - .width(min: 120, ideal: 200) + .width(min: Self.roleColumnMinWidth, ideal: Self.roleColumnIdealWidth) TableColumnForEach(model.privileges, id: \.name) { privilege in TableColumn(privilege.label) { row in - checkbox(privilege, row: row) + cell(privilege, row: row) } - .width(min: 56, ideal: 72) + .width(min: Self.privilegeColumnMinWidth, ideal: Self.privilegeColumnIdealWidth) } } .tableStyle(.inset) - .accessibilityIdentifier("schema-privilege-table") + } + + /// The same grid without AppKit's table chrome: headings and striping are drawn here, and the + /// columns hold their width instead of being resizable. + private var gridFallback: some View { + ScrollView { + Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 0) { + GridRow { + Text("Role") + .gridColumnAlignment(.leading) + .frame(minWidth: Self.roleColumnMinWidth, alignment: .leading) + + ForEach(model.privileges, id: \.name) { privilege in + Text(privilege.label) + .frame(width: Self.privilegeColumnIdealWidth, alignment: .center) + } + } + .font(.subheadline) + .foregroundStyle(.secondary) + .padding(.vertical, 4) + + Divider() + .gridCellUnsizedAxes(.horizontal) + + ForEach(Array(model.granteeRows.enumerated()), id: \.element.id) { offset, row in + GridRow { + roleLabel(row) + .frame(minWidth: Self.roleColumnMinWidth, alignment: .leading) + + ForEach(model.privileges, id: \.name) { privilege in + cell(privilege, row: row) + .frame(width: Self.privilegeColumnIdealWidth, alignment: .center) + } + } + .padding(.vertical, 4) + .background(offset.isMultiple(of: 2) ? Color.clear : Color(nsColor: .alternatingContentBackgroundColors[1])) + } + } + .padding(.horizontal, 8) + .frame(maxWidth: .infinity, alignment: .leading) + } + .background(Color(nsColor: .textBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay { + RoundedRectangle(cornerRadius: 6) + .strokeBorder(Color(nsColor: .separatorColor)) + } + } + + private func roleLabel(_ row: SchemaGranteeRow) -> some View { + Text(row.displayName) + .lineLimit(1) + .truncationMode(.middle) + .help(row.locked.isEmpty ? "" : String(localized: "Granted by another role")) } /// A cell another role granted is shown checked and dimmed. `REVOKE` removes only what the /// executing role granted, so offering it would run a statement that succeeds, changes nothing, /// and reports the access as gone while it is still there. - private func checkbox(_ privilege: PluginPrivilegeDescriptor, row: SchemaGranteeRow) -> some View { + private func cell(_ privilege: PluginPrivilegeDescriptor, row: SchemaGranteeRow) -> some View { Toggle( privilege.label, isOn: Binding( @@ -65,4 +134,9 @@ struct SchemaPrivilegeTable: View { ) ) } + + private static let roleColumnMinWidth: CGFloat = 120 + private static let roleColumnIdealWidth: CGFloat = 200 + private static let privilegeColumnMinWidth: CGFloat = 56 + private static let privilegeColumnIdealWidth: CGFloat = 72 } diff --git a/TablePro/Views/ERDiagram/ERDiagramToolbar.swift b/TablePro/Views/ERDiagram/ERDiagramToolbar.swift index 163af6a9a2..25f6666abd 100644 --- a/TablePro/Views/ERDiagram/ERDiagramToolbar.swift +++ b/TablePro/Views/ERDiagram/ERDiagramToolbar.swift @@ -1,8 +1,8 @@ import SwiftUI struct ERDiagramToolbar: View { - @Bindable var viewModel: ERDiagramViewModel - let viewport: DiagramViewportController + @ObservedObject var viewModel: ERDiagramViewModel + @ObservedObject var viewport: DiagramViewportController let onExport: () -> Void var body: some View { diff --git a/TablePro/Views/ERDiagram/ERDiagramView.swift b/TablePro/Views/ERDiagram/ERDiagramView.swift index 8e807cb4c1..ba408caf6c 100644 --- a/TablePro/Views/ERDiagram/ERDiagramView.swift +++ b/TablePro/Views/ERDiagram/ERDiagramView.swift @@ -2,7 +2,7 @@ import AppKit import SwiftUI struct ERDiagramView: View { - @Bindable var viewModel: ERDiagramViewModel + @ObservedObject var viewModel: ERDiagramViewModel @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor @Environment(\.colorScheme) private var colorScheme diff --git a/TablePro/Views/Editor/History/HistoryDetailPane.swift b/TablePro/Views/Editor/History/HistoryDetailPane.swift index 2e6f10a27c..5ea262a2e7 100644 --- a/TablePro/Views/Editor/History/HistoryDetailPane.swift +++ b/TablePro/Views/Editor/History/HistoryDetailPane.swift @@ -15,7 +15,7 @@ struct HistoryDetailPane: View { if let entry { detail(for: entry) } else { - ContentUnavailableView { + UnavailableStateView { Label(String(localized: "No Query Selected"), systemImage: "doc.text.magnifyingglass") } description: { Text("Select a query to see its full text and details.") diff --git a/TablePro/Views/Editor/History/HistoryListPane.swift b/TablePro/Views/Editor/History/HistoryListPane.swift index 02e37f7299..c36f6518d1 100644 --- a/TablePro/Views/Editor/History/HistoryListPane.swift +++ b/TablePro/Views/Editor/History/HistoryListPane.swift @@ -1,7 +1,7 @@ import SwiftUI struct HistoryListPane: View { - @Bindable var viewModel: HistoryPanelViewModel + @ObservedObject var viewModel: HistoryPanelViewModel let canRunInNewTab: (QueryHistoryEntry) -> Bool let onLoadInEditor: (QueryHistoryEntry) -> Void @@ -102,13 +102,13 @@ struct HistoryListPane: View { private var emptyState: some View { Group { if viewModel.isStoreUnavailable { - ContentUnavailableView { + UnavailableStateView { Label(String(localized: "Query History Is Unavailable"), systemImage: "exclamationmark.triangle") } description: { Text("TablePro could not open its query history database, so nothing is being recorded and nothing can be shown. Your existing history is still on disk.") } } else if viewModel.state.hasNarrowingFilter { - ContentUnavailableView { + UnavailableStateView { Label(String(localized: "No Matching Queries"), systemImage: "magnifyingglass") } description: { Text("No query matches the current search and filters.") @@ -119,7 +119,7 @@ struct HistoryListPane: View { } } } else { - ContentUnavailableView { + UnavailableStateView { Label(String(localized: "No Query History"), systemImage: "clock.arrow.circlepath") } description: { Text("Queries you run appear here.") diff --git a/TablePro/Views/Editor/History/HistoryPanelToolbar.swift b/TablePro/Views/Editor/History/HistoryPanelToolbar.swift index 72556148df..fb5507bed2 100644 --- a/TablePro/Views/Editor/History/HistoryPanelToolbar.swift +++ b/TablePro/Views/Editor/History/HistoryPanelToolbar.swift @@ -1,8 +1,8 @@ import SwiftUI struct HistoryPanelToolbar: View { - let viewModel: HistoryPanelViewModel - @Bindable var state: HistoryPanelState + @ObservedObject var viewModel: HistoryPanelViewModel + @ObservedObject var state: HistoryPanelState var body: some View { VStack(spacing: 0) { @@ -35,13 +35,13 @@ struct HistoryPanelToolbar: View { Divider() } .background(Color(nsColor: .controlBackgroundColor)) - .onChange(of: state.searchText) { + .onChange(of: state.searchText) { _ in viewModel.scheduleSearchReload() } - .onChange(of: state.showsAllConnections) { reload() } - .onChange(of: state.dateRange) { reload() } - .onChange(of: state.outcome) { reload() } - .onChange(of: state.sources) { reload() } + .onChange(of: state.showsAllConnections) { _ in reload() } + .onChange(of: state.dateRange) { _ in reload() } + .onChange(of: state.outcome) { _ in reload() } + .onChange(of: state.sources) { _ in reload() } } private var scopePicker: some View { diff --git a/TablePro/Views/Editor/History/HistoryPanelView.swift b/TablePro/Views/Editor/History/HistoryPanelView.swift index afeeeb15f1..6f05f24786 100644 --- a/TablePro/Views/Editor/History/HistoryPanelView.swift +++ b/TablePro/Views/Editor/History/HistoryPanelView.swift @@ -12,7 +12,17 @@ struct HistoryPanelView: View { /// Held directly rather than resolved from a focused value. The app runs the AppKit lifecycle /// with no SwiftUI `Scene`, so `focusedSceneValue` has nothing to publish into and every action /// that read one was silently dead. Every other call site in the app reaches actions this way. - let coordinator: MainContentCoordinator + @ObservedObject var coordinator: MainContentCoordinator + + /// Resolved once in `init`. The state is a per-connection singleton reached through a + /// factory, so it cannot be a `@StateObject` (the view does not own it) and it cannot be + /// resolved in `body` (nothing would observe it). + @ObservedObject private var panelState: HistoryPanelState + + internal init(coordinator: MainContentCoordinator) { + self.coordinator = coordinator + self.panelState = HistoryPanelState.forConnection(coordinator.connectionId) + } @Environment(\.appServices) private var services @@ -22,9 +32,7 @@ struct HistoryPanelView: View { private var connectionId: UUID { coordinator.connectionId } var body: some View { - @Bindable var panelState = HistoryPanelState.forConnection(connectionId) - - return Group { + Group { if let viewModel { panel(viewModel) } else { diff --git a/TablePro/Views/Editor/QueryEditorBar.swift b/TablePro/Views/Editor/QueryEditorBar.swift index 6d1a9c48cb..ea55ef669f 100644 --- a/TablePro/Views/Editor/QueryEditorBar.swift +++ b/TablePro/Views/Editor/QueryEditorBar.swift @@ -160,10 +160,7 @@ struct QueryEditorBar: View { .labelStyle(.iconOnly) .disabled(!commands.canOpenRunMenu) .accessibilityIdentifier("query-run-menu") - .modifier(FeatureTipPopoverAnchor( - tip: FindPastQueriesTip(shortcut: FeatureTipShortcut.display(for: .toggleHistory)), - isEnabled: showsHistoryTip - )) + .historyTipAnchor(isEnabled: showsHistoryTip) } .controlSize(.small) .fixedSize() diff --git a/TablePro/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index 09e14aeaa9..ba07af7615 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -8,16 +8,14 @@ import AppKit import Combine -import Observation import os import TableProEditorKit import TableProPluginKit import TableProTextEngine /// Coordinator for the SQL editor — manages find panel, horizontal scrolling, and scroll-to-match -@Observable @MainActor -final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { +final class SQLEditorCoordinator: ObservableObject, TextViewCoordinator, TextViewDelegate { // MARK: - Properties nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "SQLEditorCoordinator") @@ -26,38 +24,38 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { /// so a large document does not copy its whole contents to the assistant on every keystroke. private static let languageServiceLengthLimit = EditorHighlighting.maxHighlightableCharacters - @ObservationIgnored weak var controller: TextViewController? - @ObservationIgnored private lazy var diagnosticsController = QueryDiagnosticsController( + weak var controller: TextViewController? + private lazy var diagnosticsController = QueryDiagnosticsController( databaseType: databaseType ) - @ObservationIgnored private let statementRunController = StatementRunController() + private let statementRunController = StatementRunController() /// Shared schema provider for inline AI suggestions (avoids duplicate schema fetches) - @ObservationIgnored var schemaProvider: SQLSchemaProvider? + var schemaProvider: SQLSchemaProvider? /// Connection-level AI policy for inline suggestions - @ObservationIgnored var connectionAIPolicy: AIConnectionPolicy? - @ObservationIgnored private var contextMenu: AIEditorContextMenu? - @ObservationIgnored private var inlineSuggestionManager: InlineSuggestionManager? - @ObservationIgnored private var aiChatInlineSource: AIChatInlineSource? - @ObservationIgnored private var copilotDocumentSync: CopilotDocumentSync? - @ObservationIgnored private var copilotInlineSource: CopilotInlineSource? - @ObservationIgnored private var editorSettingsCancellable: AnyCancellable? - @ObservationIgnored private var aiSettingsCancellable: AnyCancellable? - @ObservationIgnored private var lastInlineSourceKind: InlineSourceKind = .off + var connectionAIPolicy: AIConnectionPolicy? + private var contextMenu: AIEditorContextMenu? + private var inlineSuggestionManager: InlineSuggestionManager? + private var aiChatInlineSource: AIChatInlineSource? + private var copilotDocumentSync: CopilotDocumentSync? + private var copilotInlineSource: CopilotInlineSource? + private var editorSettingsCancellable: AnyCancellable? + private var aiSettingsCancellable: AnyCancellable? + private var lastInlineSourceKind: InlineSourceKind = .off /// Debounce work item for frame-change notification to avoid /// triggering syntax highlight viewport recalculation on every keystroke. - @ObservationIgnored private var frameChangeTask: Task? - @ObservationIgnored private var isUppercasing = false - @ObservationIgnored private var wasEditorFocused = false - @ObservationIgnored private var didDestroy = false - @ObservationIgnored private var focusClaimPending = false + private var frameChangeTask: Task? + private var isUppercasing = false + private var wasEditorFocused = false + private var didDestroy = false + private var focusClaimPending = false /// One way. `destroy()` runs when the editor is dismantled, which it never comes back from. var isDestroyed: Bool { didDestroy } - @ObservationIgnored private var hasInstalledEditorServices = false - @ObservationIgnored private weak var windowSentinel: WindowAccessorView? + private var hasInstalledEditorServices = false + private weak var windowSentinel: WindowAccessorView? - @ObservationIgnored private var cursorRestorePending: NSRange? + private var cursorRestorePending: NSRange? var pendingFocusClaim: Bool { focusClaimPending } @@ -78,7 +76,7 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { cursorRestorePending = range } - @ObservationIgnored private var foldRestorePending: [Range]? + private var foldRestorePending: [Range]? /// Collapsed folds are replayed once, the same way the cursor is, because the fold state the editor reports back /// is written on every collapse the user makes. @@ -100,20 +98,20 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { } /// Vim mode for UI observation - private(set) var vimMode: VimMode = .normal - @ObservationIgnored private var vimEngine: VimEngine? - @ObservationIgnored private var vimKeyInterceptor: VimKeyInterceptor? - @ObservationIgnored private var commandHandler = VimCommandLineHandler() - @ObservationIgnored private var vimCursorManager: VimCursorManager? - @ObservationIgnored var onCloseTab: (() -> Void)? - @ObservationIgnored var onExecuteQuery: (() -> Void)? - @ObservationIgnored var onRunStatement: ((String, Int) -> Bool)? - @ObservationIgnored var onAIExplain: ((String) -> Void)? - @ObservationIgnored var onAIOptimize: ((String) -> Void)? - @ObservationIgnored var onSaveAsFavorite: ((String) -> Void)? - @ObservationIgnored var databaseType: DatabaseType? - @ObservationIgnored var tabID: UUID? - @ObservationIgnored var connectionId: UUID? + @Published private(set) var vimMode: VimMode = .normal + private var vimEngine: VimEngine? + private var vimKeyInterceptor: VimKeyInterceptor? + private var commandHandler = VimCommandLineHandler() + private var vimCursorManager: VimCursorManager? + var onCloseTab: (() -> Void)? + var onExecuteQuery: (() -> Void)? + var onRunStatement: ((String, Int) -> Bool)? + var onAIExplain: ((String) -> Void)? + var onAIOptimize: ((String) -> Void)? + var onSaveAsFavorite: ((String) -> Void)? + var databaseType: DatabaseType? + var tabID: UUID? + var connectionId: UUID? /// Whether the editor text view is currently the first responder. /// Used to guard cursor propagation — when the find panel highlights @@ -420,7 +418,7 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { controller.textView?.menu = menu } - @ObservationIgnored private let foldPreview = FoldPreviewController() + private let foldPreview = FoldPreviewController() func toggleFoldAtCursor() { controller?.toggleFoldAtCursor() diff --git a/TablePro/Views/Editor/SQLEditorView.swift b/TablePro/Views/Editor/SQLEditorView.swift index 16096d7aad..0d72ab3062 100644 --- a/TablePro/Views/Editor/SQLEditorView.swift +++ b/TablePro/Views/Editor/SQLEditorView.swift @@ -47,7 +47,7 @@ struct SQLEditorView: View { @State private var editorState = SourceEditorState() @State private var completionAdapter = QueryCompletionAdapter(schemaProvider: nil, databaseType: nil) - @State private var coordinator = SQLEditorCoordinator() + @StateObject private var coordinator = SQLEditorCoordinator() @State private var editorConfiguration = makeConfiguration() @State private var favoritesCancellables: Set = [] @Environment(\.colorScheme) private var colorScheme @@ -92,12 +92,12 @@ struct SQLEditorView: View { /// Applied on change rather than while building the view: this is an event, and an editor that is already /// mounted never rebuilds from scratch to notice a new value. Cleared whether or not the statement was still /// there, so a request that cannot be honoured does not sit pending and block the next one. - .onChange(of: pendingStatementJump) { _, newValue in + .onChange(of: pendingStatementJump) { newValue in guard let newValue else { return } coordinator.jumpToStatement(newValue) onStatementJumpHandled?() } - .onChange(of: editorState.cursorPositions) { _, newValue in + .onChange(of: editorState.cursorPositions) { newValue in guard let positions = newValue else { return } // Skip cursor propagation when the editor doesn't have focus // (e.g., find panel match highlighting). Propagating triggers @@ -115,13 +115,13 @@ struct SQLEditorView: View { } cursorPositions = positions } - .onChange(of: editorState.collapsedFoldRanges) { _, newValue in + .onChange(of: editorState.collapsedFoldRanges) { newValue in onFoldRangesChanged?(newValue ?? []) } - .onChange(of: tabID) { _, _ in + .onChange(of: tabID) { _ in coordinator.repointFolds(to: restoredFoldRanges) } - .onChange(of: connectionId) { _, _ in + .onChange(of: connectionId) { _ in configureCompletion() setupFavoritesObserver() } @@ -129,17 +129,17 @@ struct SQLEditorView: View { /// moves. Without this the editor keeps completing against the previous database's /// provider until the profile resolution returns, which leases a metadata driver and on a /// non-poolable engine can queue behind a running query. - .onChange(of: databaseScope) { _, _ in + .onChange(of: databaseScope) { _ in completionProfile = nil configureCompletion() } .task(id: completionProfileRequest) { await resolveCompletionProfile() } - .onChange(of: colorScheme) { + .onChange(of: colorScheme) { _ in editorConfiguration = Self.makeConfiguration() } - .onChange(of: AppSettingsManager.shared.editor) { + .onChange(of: AppSettingsManager.shared.editor) { _ in editorConfiguration = Self.makeConfiguration() } .onReceive(AppEvents.shared.accessibilityTextSizeChanged) { _ in @@ -154,7 +154,7 @@ struct SQLEditorView: View { .onDisappear { teardownFavoritesObserver() } - .onChange(of: coordinator.vimMode) { _, newMode in + .onChange(of: coordinator.vimMode) { newMode in vimMode = newMode } } diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index 2461d6838b..117606d61c 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -74,16 +74,6 @@ struct ExportDialog: View { return 0 } - /// The name the progress sheet puts in front of the user. A streaming query has no current - /// table, so it is named by the file it is being written to instead of by an empty string. - private var progressSubject: String { - let currentTable = exportService?.state.currentTable ?? "" - guard currentTable.isEmpty else { return currentTable } - return config.fileName.isEmpty - ? String(localized: "Query results") - : config.fileName - } - private var preselection: ExportPreselection { if case .tables(_, let preselection) = mode { return preselection @@ -140,7 +130,7 @@ struct ExportDialog: View { restoreSettingsSnapshot() } } - .onChange(of: config.formatId) { + .onChange(of: config.formatId) { _ in resetOptionValues() Task { await reconcileObjectKindsForFormat() } } @@ -166,20 +156,15 @@ struct ExportDialog: View { } } .sheet(isPresented: $showProgressDialog) { - ExportProgressView( - subject: progressSubject, - tableIndex: exportService?.state.currentTableIndex ?? 0, - totalTables: exportService?.state.totalTables ?? 0, - processedRows: exportService?.state.processedRows ?? 0, - totalRows: exportService?.state.totalRows ?? 0, - statusMessage: exportService?.state.statusMessage ?? "" - ) { - exportService?.cancelExport() + if let exportService { + ExportProgressSheet(service: exportService, fileName: config.fileName) { + exportService.cancelExport() + } + .interactiveDismissDisabled() + .onExitCommand { } } - .interactiveDismissDisabled() - .onExitCommand { } } - .onChange(of: showSuccessDialog) { _, isShowing in + .onChange(of: showSuccessDialog) { isShowing in guard isShowing else { return } TransferResultAlert.presentExportSuccess( warnings: exportService?.state.warnings ?? [], @@ -485,15 +470,11 @@ struct ExportDialog: View { private var footerView: some View { DialogFooter { - if isExporting { + if isExporting, let exportService { ProgressView() .scaleEffect(0.7) - Text(exportService?.state.currentTable ?? "") - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) + ExportCurrentTableLabel(service: exportService) } } actions: { Button("Cancel") { @@ -941,3 +922,42 @@ struct ExportDialog: View { mode: .tables(connection: connection, preselection: .tables(names: ["users"], scope: nil)) ) } + +/// Observes the service so the progress sheet advances. The dialog holds the service in an +/// optional, which no property wrapper can observe, so the subscription lives here instead. +private struct ExportProgressSheet: View { + @ObservedObject var service: ExportService + let fileName: String + let onStop: () -> Void + + var body: some View { + ExportProgressView( + subject: subject, + tableIndex: service.state.currentTableIndex, + totalTables: service.state.totalTables, + processedRows: service.state.processedRows, + totalRows: service.state.totalRows, + statusMessage: service.state.statusMessage, + onStop: onStop + ) + } + + /// A streaming query has no current table, so it is named by the file it is being written + /// to instead of by an empty string. + private var subject: String { + guard service.state.currentTable.isEmpty else { return service.state.currentTable } + return fileName.isEmpty ? String(localized: "Query results") : fileName + } +} + +private struct ExportCurrentTableLabel: View { + @ObservedObject var service: ExportService + + var body: some View { + Text(service.state.currentTable) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } +} diff --git a/TablePro/Views/Export/ExportRowScopeEditor.swift b/TablePro/Views/Export/ExportRowScopeEditor.swift index 49aec994ff..67d9f41ba2 100644 --- a/TablePro/Views/Export/ExportRowScopeEditor.swift +++ b/TablePro/Views/Export/ExportRowScopeEditor.swift @@ -55,7 +55,7 @@ internal struct ExportRowScopeEditor: View { TextField("All rows", text: $rowLimitText) .textFieldStyle(.roundedBorder) .frame(width: 120) - .onChange(of: rowLimitText) { _, entered in + .onChange(of: rowLimitText) { entered in let digits = entered.filter(\.isWholeNumber) if digits != entered { rowLimitText = digits } } diff --git a/TablePro/Views/Export/TableTransferSheet.swift b/TablePro/Views/Export/TableTransferSheet.swift index 1544c62e7a..9d93e654c9 100644 --- a/TablePro/Views/Export/TableTransferSheet.swift +++ b/TablePro/Views/Export/TableTransferSheet.swift @@ -23,7 +23,7 @@ struct TableTransferSheet: View { /// session happened to be browsing and matched the bare names against that one instead. var preselectedSchema: String? - @State private var service = TableTransferService() + @StateObject private var service = TableTransferService() @State private var destinationConnectionId: UUID? @State private var destinationDatabase = "" @State private var availableDestinations: [DatabaseConnection] = [] @@ -115,7 +115,7 @@ struct TableTransferSheet: View { Text(connection.name).tag(UUID?.some(connection.id)) } } - .onChange(of: destinationConnectionId) { + .onChange(of: destinationConnectionId) { _ in Task { await loadDestinationDatabases() await loadColumnsForSelection() diff --git a/TablePro/Views/Filter/FilterPanelView.swift b/TablePro/Views/Filter/FilterPanelView.swift index 6fbbb6549d..38f4c0e1f4 100644 --- a/TablePro/Views/Filter/FilterPanelView.swift +++ b/TablePro/Views/Filter/FilterPanelView.swift @@ -7,7 +7,7 @@ import SwiftUI import TableProPluginKit struct FilterPanelView: View { - let coordinator: MainContentCoordinator + @ObservedObject var coordinator: MainContentCoordinator let columns: [String] let primaryKeyColumn: String? let databaseType: DatabaseType @@ -57,14 +57,14 @@ struct FilterPanelView: View { focusedFilterId = filterState.filters.last?.id refreshRawSQLCompletionProvider() } - .onChange(of: columns) { _, newColumns in + .onChange(of: columns) { newColumns in if filterState.filters.isEmpty && !newColumns.isEmpty && filterState.isVisible { coordinator.addFilter(columns: newColumns, primaryKeyColumn: primaryKeyColumn) focusedFilterId = filterState.filters.last?.id } refreshRawSQLCompletionProvider() } - .onChange(of: coordinator.currentTableName) { _, _ in + .onChange(of: coordinator.currentTableName) { _ in refreshRawSQLCompletionProvider() } .task(id: coordinator.currentTableName) { diff --git a/TablePro/Views/Filter/FilterRowView.swift b/TablePro/Views/Filter/FilterRowView.swift index 9cb0c777bc..320bce333b 100644 --- a/TablePro/Views/Filter/FilterRowView.swift +++ b/TablePro/Views/Filter/FilterRowView.swift @@ -201,7 +201,7 @@ struct FilterRowView: View { nestedFieldPathButton } } - .onChange(of: filter.columnName) { _, _ in + .onChange(of: filter.columnName) { _ in filter.elementScope = nil } } diff --git a/TablePro/Views/Filter/FilterSettingsPopover.swift b/TablePro/Views/Filter/FilterSettingsPopover.swift index 3cc5ad7995..40d6a85e19 100644 --- a/TablePro/Views/Filter/FilterSettingsPopover.swift +++ b/TablePro/Views/Filter/FilterSettingsPopover.swift @@ -38,7 +38,7 @@ struct FilterSettingsPopover: View { } .formStyle(.grouped) .frame(width: 280) - .onChange(of: settings) { _, newValue in + .onChange(of: settings) { newValue in FilterSettingsStorage.shared.saveSettings(newValue) } } diff --git a/TablePro/Views/Filter/FilterValueTextField.swift b/TablePro/Views/Filter/FilterValueTextField.swift index 9356303d61..2e2e862474 100644 --- a/TablePro/Views/Filter/FilterValueTextField.swift +++ b/TablePro/Views/Filter/FilterValueTextField.swift @@ -594,7 +594,7 @@ struct FilterValueTextField: NSViewRepresentable { .padding(4) } .focusable(false) - .onChange(of: state.selectedIndex) { _, newIndex in + .onChange(of: state.selectedIndex) { newIndex in withMotion(.easeOut(duration: 0.1)) { proxy.scrollTo(newIndex, anchor: .center) } diff --git a/TablePro/Views/Filter/KeyPatternSearchBar.swift b/TablePro/Views/Filter/KeyPatternSearchBar.swift index 83b7e6c004..cef8548f24 100644 --- a/TablePro/Views/Filter/KeyPatternSearchBar.swift +++ b/TablePro/Views/Filter/KeyPatternSearchBar.swift @@ -2,7 +2,7 @@ import SwiftUI import TableProPluginKit struct KeyPatternSearchBar: View { - let coordinator: MainContentCoordinator + @ObservedObject var coordinator: MainContentCoordinator let descriptor: BrowseFilterDescriptor @State private var pattern: String = "" @@ -35,7 +35,7 @@ struct KeyPatternSearchBar: View { /// width of its widest one, so an unbounded one could make this bar wider than the /// pane and clip the grid beside it. .frame(maxWidth: Self.typeScopeMaximumWidth) - .onChange(of: typeScope) { _, _ in apply() } + .onChange(of: typeScope) { _ in apply() } } if isActive { @@ -48,7 +48,7 @@ struct KeyPatternSearchBar: View { .padding(.horizontal, 12) .padding(.vertical, 6) .onAppear(perform: syncFromState) - .onChange(of: coordinator.selectedTabFilterState.browseSearch) { _, _ in + .onChange(of: coordinator.selectedTabFilterState.browseSearch) { _ in syncFromState() } } diff --git a/TablePro/Views/Filter/SQLPreviewSheet.swift b/TablePro/Views/Filter/SQLPreviewSheet.swift index cce28ed845..0236ba5fc5 100644 --- a/TablePro/Views/Filter/SQLPreviewSheet.swift +++ b/TablePro/Views/Filter/SQLPreviewSheet.swift @@ -63,7 +63,7 @@ struct SQLPreviewSheet: View { private func copyToClipboard() { ClipboardService.shared.writeText(sql) copied = true - AccessibilityNotification.Announcement(String(localized: "Copied to clipboard")).post() + AccessibilityAnnouncement.post(String(localized: "Copied to clipboard")) Task { @MainActor in try? await Task.sleep(for: .seconds(1.5)) diff --git a/TablePro/Views/Highlight/HighlightMenuBuilder.swift b/TablePro/Views/Highlight/HighlightMenuBuilder.swift index 537505cbba..9def674c96 100644 --- a/TablePro/Views/Highlight/HighlightMenuBuilder.swift +++ b/TablePro/Views/Highlight/HighlightMenuBuilder.swift @@ -94,7 +94,7 @@ enum HighlightMenuBuilder { for template in templates { let existing = context.existingRules.first { $0.hasSameCondition(as: template) } if let existing { existingMatches.append(existing) } - submenu.addItem(.sectionHeader(title: sectionTitle(for: template))) + submenu.addItem(.sectionHeaderCompat(title: sectionTitle(for: template))) submenu.addItem(paletteItem(for: template, existing: existing, actions: actions)) } @@ -118,12 +118,8 @@ enum HighlightMenuBuilder { actions: Actions ) -> NSMenuItem { let colors = HighlightColor.allCases - let palette = NSMenu.palette( - colors: colors.map(\.systemColor), - titles: colors.map(\.displayName) - ) { menu in - let selected = menu.selectedItems.compactMap { menu.items.firstIndex(of: $0) } - guard let index = selected.first, colors.indices.contains(index) else { + let apply: (Int) -> Void = { index in + guard colors.indices.contains(index) else { if let existing { actions.remove(existing) } return } @@ -132,13 +128,46 @@ enum HighlightMenuBuilder { rule.isEnabled = true actions.apply(rule) } - palette.selectionMode = .selectOne - if let existing, let index = colors.firstIndex(of: existing.color), index < palette.items.count { - palette.selectedItems = [palette.items[index]] + + let palette: NSMenu + if #available(macOS 14.0, *) { + let menu = NSMenu.palette( + colors: colors.map(\.systemColor), + titles: colors.map(\.displayName) + ) { menu in + apply(menu.selectedItems.compactMap { menu.items.firstIndex(of: $0) }.first ?? -1) + } + menu.selectionMode = .selectOne + if let existing, let index = colors.firstIndex(of: existing.color), index < menu.items.count { + menu.selectedItems = [menu.items[index]] + } + palette = menu + } else { + /// `NSMenu.palette` is macOS 14. The fallback is a plain menu of the same colours, + /// each drawn with its own swatch and check-marked when it is the rule's colour, so + /// the same choice is offered a row at a time instead of as one strip. + let menu = NSMenu() + for (index, color) in colors.enumerated() { + let entry = ClosureMenuTarget.item(title: color.displayName) { apply(index) } + entry.image = Self.swatch(for: color.systemColor) + entry.state = existing?.color == color ? .on : .off + menu.addItem(entry) + } + palette = menu } let item = NSMenuItem(title: sectionTitle(for: template), action: nil, keyEquivalent: "") item.submenu = palette return item } + + private static func swatch(for color: NSColor) -> NSImage { + let size = NSSize(width: 12, height: 12) + let image = NSImage(size: size) + image.lockFocus() + color.setFill() + NSBezierPath(ovalIn: NSRect(origin: .zero, size: size)).fill() + image.unlockFocus() + return image + } } diff --git a/TablePro/Views/Highlight/HighlightRulesPopover.swift b/TablePro/Views/Highlight/HighlightRulesPopover.swift index 642004f9c0..feca974b40 100644 --- a/TablePro/Views/Highlight/HighlightRulesPopover.swift +++ b/TablePro/Views/Highlight/HighlightRulesPopover.swift @@ -49,7 +49,7 @@ struct HighlightRulesPopover: View { } private var emptyState: some View { - ContentUnavailableView { + UnavailableStateView { Label(String(localized: "No Highlight Rules"), systemImage: "highlighter") } description: { Text("Right-click a cell and choose Highlight to color rows by value.") diff --git a/TablePro/Views/Import/ImportDialog.swift b/TablePro/Views/Import/ImportDialog.swift index cd8b3c2cd5..8a1c38411d 100644 --- a/TablePro/Views/Import/ImportDialog.swift +++ b/TablePro/Views/Import/ImportDialog.swift @@ -125,7 +125,7 @@ struct ImportDialog: View { .interactiveDismissDisabled() } } - .onChange(of: showSuccessDialog) { _, isShowing in + .onChange(of: showSuccessDialog) { isShowing in guard isShowing else { return } TransferResultAlert.presentImportSuccess( result: importResult, @@ -138,7 +138,7 @@ struct ImportDialog: View { AppCommands.shared.refreshData.send(DataRefreshRequest(connectionId: connection.id)) } } - .onChange(of: showErrorDialog) { _, isShowing in + .onChange(of: showErrorDialog) { isShowing in guard isShowing else { return } TransferResultAlert.presentImportFailure(error: importError, window: hostWindow) { showErrorDialog = false @@ -285,7 +285,7 @@ struct ImportDialog: View { .pickerStyle(.menu) .labelsHidden() .frame(width: 120) - .onChange(of: selectedEncoding) { _, _ in + .onChange(of: selectedEncoding) { _ in loadFileTask?.cancel() if let url = fileURL { loadFileTask = Task { diff --git a/TablePro/Views/Import/ImportProgressView.swift b/TablePro/Views/Import/ImportProgressView.swift index d98226abfe..a1df385e81 100644 --- a/TablePro/Views/Import/ImportProgressView.swift +++ b/TablePro/Views/Import/ImportProgressView.swift @@ -10,7 +10,7 @@ import SwiftUI /// Stopping asks first, the way the export and backup sheets do. An import writes rows, so an /// accidental press is the expensive one of the three: statements already run stay committed. struct ImportProgressView: View { - let service: ImportService + @ObservedObject var service: ImportService let onStop: () -> Void @State private var showStopConfirmation = false diff --git a/TablePro/Views/Import/RowImportSheet.swift b/TablePro/Views/Import/RowImportSheet.swift index edfcea5fbd..0994ceba67 100644 --- a/TablePro/Views/Import/RowImportSheet.swift +++ b/TablePro/Views/Import/RowImportSheet.swift @@ -165,18 +165,18 @@ struct RowImportSheet: View { await loadTables() await loadNewColumns() } - .onChange(of: destination) { _, newValue in + .onChange(of: destination) { newValue in guard newValue == .newTable else { return } suggestNewTableName() newTableNameFocused = true } - .onChange(of: selectedTargetTable) { _, newValue in + .onChange(of: selectedTargetTable) { newValue in mappings = [] targetColumns = [] guard destination == .existingTable, let table = newValue else { return } Task { await loadExistingContext(table: table) } } - .onChange(of: currentPlugin?.fieldDetectionSignature) { _, _ in + .onChange(of: currentPlugin?.fieldDetectionSignature) { _ in Task { await redetectFields() } } .onDisappear { @@ -190,7 +190,7 @@ struct RowImportSheet: View { .interactiveDismissDisabled() } } - .onChange(of: showSuccessDialog) { _, isShowing in + .onChange(of: showSuccessDialog) { isShowing in guard isShowing else { return } TransferResultAlert.presentImportSuccess( result: importResult, @@ -203,7 +203,7 @@ struct RowImportSheet: View { AppCommands.shared.refreshData.send(DataRefreshRequest(connectionId: connection.id)) } } - .onChange(of: showErrorDialog) { _, isShowing in + .onChange(of: showErrorDialog) { isShowing in guard isShowing else { return } TransferResultAlert.presentImportFailure(error: importError, window: hostWindow) { showErrorDialog = false @@ -358,7 +358,7 @@ struct RowImportSheet: View { /// A file the plugin could not read is a failure, not an empty result. Showing the parser's /// message as grey placeholder text left the sheet with nothing to press but Cancel. private func unreadableFile(reason: String) -> some View { - ContentUnavailableView { + UnavailableStateView { Label(String(localized: "Cannot read this file"), systemImage: "exclamationmark.triangle") } description: { Text(reason) diff --git a/TablePro/Views/Import/SQLCodePreview.swift b/TablePro/Views/Import/SQLCodePreview.swift index a34e21ccef..7aeab9f17e 100644 --- a/TablePro/Views/Import/SQLCodePreview.swift +++ b/TablePro/Views/Import/SQLCodePreview.swift @@ -28,7 +28,7 @@ struct SQLCodePreview: View { state: $editorState, foldProvider: FoldProviderResolver.provider(for: CodeLanguage.sql) ) - .onChange(of: colorScheme) { + .onChange(of: colorScheme) { _ in editorConfiguration = Self.makeConfiguration() } } diff --git a/TablePro/Views/Inspector/InspectorFilterBar.swift b/TablePro/Views/Inspector/InspectorFilterBar.swift index f20eb97d63..45754cc7ee 100644 --- a/TablePro/Views/Inspector/InspectorFilterBar.swift +++ b/TablePro/Views/Inspector/InspectorFilterBar.swift @@ -63,7 +63,7 @@ extension FilterClause: Equatable { } struct InspectorFilterBar: View { - @Bindable var state: InspectorViewState + @ObservedObject var state: InspectorViewState let onChange: () -> Void var body: some View { @@ -114,7 +114,7 @@ struct InspectorFilterBar: View { } .labelsHidden() .frame(maxWidth: 180) - .onChange(of: clause.column) { _, _ in onChange() } + .onChange(of: clause.column) { _ in onChange() } Picker("", selection: binding.op) { ForEach(CSVFilterOperator.allCases) { op in @@ -123,13 +123,13 @@ struct InspectorFilterBar: View { } .labelsHidden() .frame(maxWidth: 160) - .onChange(of: clause.op) { _, _ in onChange() } + .onChange(of: clause.op) { _ in onChange() } if clause.op.needsValue { TextField(String(localized: "Filter value"), text: binding.value) .textFieldStyle(.roundedBorder) .frame(maxWidth: 260) - .onChange(of: clause.value) { _, _ in onChange() } + .onChange(of: clause.value) { _ in onChange() } } else { Color.clear.frame(maxWidth: 260) } diff --git a/TablePro/Views/Inspector/InspectorStatusBar.swift b/TablePro/Views/Inspector/InspectorStatusBar.swift index 5ec23773ca..c3bc6b2e9b 100644 --- a/TablePro/Views/Inspector/InspectorStatusBar.swift +++ b/TablePro/Views/Inspector/InspectorStatusBar.swift @@ -6,7 +6,7 @@ import SwiftUI struct InspectorStatusBar: View { - @Bindable var state: InspectorViewState + @ObservedObject var state: InspectorViewState let onPreviousPage: () -> Void let onNextPage: () -> Void diff --git a/TablePro/Views/Inspector/InspectorViewController.swift b/TablePro/Views/Inspector/InspectorViewController.swift index 6efc67adbc..d866ced55f 100644 --- a/TablePro/Views/Inspector/InspectorViewController.swift +++ b/TablePro/Views/Inspector/InspectorViewController.swift @@ -4,6 +4,7 @@ // import AppKit +import Combine import SwiftUI import TableProPluginKit @@ -950,21 +951,20 @@ private enum SortKey: Sendable { } @MainActor -@Observable -final class InspectorViewState { - var tableRows = TableRows() - var selectedRowIndices: Set = [] - var sortState = SortState() - var columnLayout = ColumnLayoutState() - var columnNames: [String] = [] - var totalRowCount: Int = 0 - var visibleRowCount: Int = 0 - var pageOffset: Int = 0 - var pageSize: Int = 1_000 - var pageCount: Int = 1 - var isComputing: Bool = false - var isFilterVisible: Bool = false - var filters: [FilterClause] = [] +final class InspectorViewState: ObservableObject { + @Published var tableRows = TableRows() + @Published var selectedRowIndices: Set = [] + @Published var sortState = SortState() + @Published var columnLayout = ColumnLayoutState() + @Published var columnNames: [String] = [] + @Published var totalRowCount: Int = 0 + @Published var visibleRowCount: Int = 0 + @Published var pageOffset: Int = 0 + @Published var pageSize: Int = 1_000 + @Published var pageCount: Int = 1 + @Published var isComputing: Bool = false + @Published var isFilterVisible: Bool = false + @Published var filters: [FilterClause] = [] } @MainActor @@ -1022,8 +1022,8 @@ private final class InspectorGridDelegate: DataGridViewDelegate { } private struct InspectorRootView: View { - @Bindable var state: InspectorViewState - let changeManager: AnyChangeManager + @ObservedObject var state: InspectorViewState + @ObservedObject var changeManager: AnyChangeManager let delegate: any DataGridViewDelegate let onFilterChanged: () -> Void let onPreviousPage: () -> Void @@ -1062,7 +1062,7 @@ private struct InspectorRootView: View { } private var emptyStateView: some View { - ContentUnavailableView( + UnavailableStateView( state.totalRowCount == 0 ? String(localized: "No rows") : String(localized: "No matching rows"), diff --git a/TablePro/Views/Integrations/IntegrationsActivityLogPane.swift b/TablePro/Views/Integrations/IntegrationsActivityLogPane.swift index 3b63265027..cbc2367a57 100644 --- a/TablePro/Views/Integrations/IntegrationsActivityLogPane.swift +++ b/TablePro/Views/Integrations/IntegrationsActivityLogPane.swift @@ -32,11 +32,11 @@ struct IntegrationsActivityLogPane: View { ) .overlay(alignment: .center) { overlay } .searchable(text: $searchText, placement: .toolbar, prompt: Text(String(localized: "Search activity"))) - .inspector(isPresented: $showInspector) { - ActivityLogInspector(entry: selectedEntry, - connectionLabel: connectionName) - .inspectorColumnWidth(min: 260, ideal: 320, max: 480) - } + .modifier(ActivityLogInspectorPane( + isPresented: $showInspector, + entry: selectedEntry, + connectionLabel: connectionName + )) .toolbar(content: toolbar) .navigationTitle(IntegrationsActivitySection.activityLog.title) .navigationSubtitle(retentionSubtitle) @@ -44,10 +44,10 @@ struct IntegrationsActivityLogPane: View { .onReceive(AppEvents.shared.mcpAuditLogChanged) { _ in Task { await reload() } } - .onChange(of: selectedTokenId) { _, _ in Task { await reload() } } - .onChange(of: selectedCategory) { _, _ in Task { await reload() } } - .onChange(of: selectedRange) { _, _ in Task { await reload() } } - .onChange(of: sortOrder) { _, newValue in + .onChange(of: selectedTokenId) { _ in Task { await reload() } } + .onChange(of: selectedCategory) { _ in Task { await reload() } } + .onChange(of: selectedRange) { _ in Task { await reload() } } + .onChange(of: sortOrder) { newValue in entries.sort(using: newValue) } } @@ -70,15 +70,15 @@ struct IntegrationsActivityLogPane: View { @ViewBuilder private var emptyState: some View { if !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - ContentUnavailableView.search(text: searchText) + UnavailableStateView.search(text: searchText) } else if hasNoFilters { - ContentUnavailableView( + UnavailableStateView( String(localized: "No activity yet"), systemImage: "tray", description: Text(String(localized: "External integrations and MCP client requests will appear here.")) ) } else { - ContentUnavailableView( + UnavailableStateView( String(localized: "No matching activity"), systemImage: "line.3.horizontal.decrease.circle", description: Text(String(localized: "No activity matches the current filters.")) @@ -417,7 +417,7 @@ private struct ActivityLogInspector: View { if let entry { detailForm(for: entry) } else { - ContentUnavailableView( + UnavailableStateView( String(localized: "No Selection"), systemImage: "list.bullet.rectangle", description: Text(String(localized: "Select an activity entry to see its details.")) @@ -516,3 +516,30 @@ enum ActivityTimeRange: String, CaseIterable, Identifiable { } } } + +/// `.inspector` and `.inspectorColumnWidth` are macOS 14. Before them the pane is shown beside +/// the log in an `HSplitView`, which is the same arrangement without the system's show/hide +/// animation and its width persistence. +private struct ActivityLogInspectorPane: ViewModifier { + @Binding var isPresented: Bool + let entry: AuditEntry? + let connectionLabel: (UUID?) -> String? + + @ViewBuilder + func body(content: Content) -> some View { + if #available(macOS 14.0, *) { + content.inspector(isPresented: $isPresented) { + ActivityLogInspector(entry: entry, connectionLabel: connectionLabel) + .inspectorColumnWidth(min: 260, ideal: 320, max: 480) + } + } else { + HSplitView { + content + if isPresented { + ActivityLogInspector(entry: entry, connectionLabel: connectionLabel) + .frame(minWidth: 260, idealWidth: 320, maxWidth: 480) + } + } + } + } +} diff --git a/TablePro/Views/Integrations/IntegrationsActivityView.swift b/TablePro/Views/Integrations/IntegrationsActivityView.swift index 72a43d78af..71835938ef 100644 --- a/TablePro/Views/Integrations/IntegrationsActivityView.swift +++ b/TablePro/Views/Integrations/IntegrationsActivityView.swift @@ -66,7 +66,7 @@ struct IntegrationsActivityView: View { case .connectedClients: IntegrationsConnectedClientsPane() case .none: - ContentUnavailableView( + UnavailableStateView( String(localized: "No Selection"), systemImage: "sidebar.left", description: Text(String(localized: "Choose a section from the sidebar.")) diff --git a/TablePro/Views/Integrations/IntegrationsConnectedClientsPane.swift b/TablePro/Views/Integrations/IntegrationsConnectedClientsPane.swift index d77b110a19..f0504e22d1 100644 --- a/TablePro/Views/Integrations/IntegrationsConnectedClientsPane.swift +++ b/TablePro/Views/Integrations/IntegrationsConnectedClientsPane.swift @@ -6,7 +6,7 @@ import SwiftUI struct IntegrationsConnectedClientsPane: View { - @State private var manager = MCPServerManager.shared + @ObservedObject private var manager = MCPServerManager.shared @State private var selection: MCPServerManager.SessionSnapshot.ID? @State private var disconnectCandidate: MCPServerManager.SessionSnapshot? @State private var sortOrder: [KeyPathComparator] = [ @@ -16,7 +16,7 @@ struct IntegrationsConnectedClientsPane: View { var body: some View { Group { if manager.connectedClients.isEmpty { - ContentUnavailableView( + UnavailableStateView( String(localized: "No clients connected"), systemImage: "person.2.slash", description: Text(String(localized: "Clients will appear here while they have an active MCP session.")) diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index bff8fbd59f..6413a9fb1c 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -28,16 +28,20 @@ struct MainEditorContentView: View { // MARK: - Dependencies - var tabManager: QueryTabManager - var coordinator: MainContentCoordinator - var changeManager: DataChangeManager + @ObservedObject var tabManager: QueryTabManager + @ObservedObject var coordinator: MainContentCoordinator + + /// The drawer state is a per-connection singleton behind a factory, so it is handed in + /// rather than resolved in `body`, where nothing would observe it. + @ObservedObject var historyState: HistoryPanelState + @ObservedObject var changeManager: DataChangeManager let connection: DatabaseConnection let windowId: UUID let connectionId: UUID // MARK: - Selection State - let selectionState: GridSelectionState + @ObservedObject var selectionState: GridSelectionState // MARK: - Callbacks @@ -65,7 +69,7 @@ struct MainEditorContentView: View { @State private var queryInsightsViewModels: [UUID: QueryInsightsViewModel] = [:] @State private var dataTabDelegate = DataTabGridDelegate() - @Bindable private var treeService = DatabaseTreeMetadataService.shared + @ObservedObject private var treeService = DatabaseTreeMetadataService.shared // Native macOS window tabs — no LRU tracking needed (single tab per window) @@ -92,9 +96,7 @@ struct MainEditorContentView: View { // MARK: - Body var body: some View { - @Bindable var historyState = HistoryPanelState.forConnection(connectionId) - - return VerticalCollapsibleSplitView( + VerticalCollapsibleSplitView( isBottomCollapsed: Binding( get: { !historyState.isVisible }, set: { historyState.isVisible = !$0 } @@ -114,9 +116,18 @@ struct MainEditorContentView: View { } ) .background(.background) - .onChange(of: historyState.isVisible, initial: true) { _, isVisible in + .onAppear { + if historyState.isVisible { + if #available(macOS 14.0, *) { + FeatureTipSignals.queryHistoryShown() + } + } + } + .onChange(of: historyState.isVisible) { isVisible in if isVisible { - FeatureTipSignals.queryHistoryShown() + if #available(macOS 14.0, *) { + FeatureTipSignals.queryHistoryShown() + } } } .sheet(item: Binding( @@ -155,7 +166,7 @@ struct MainEditorContentView: View { } ) } - .onChange(of: tabManager.tabStructureVersion) { _, _ in + .onChange(of: tabManager.tabStructureVersion) { _ in let openTabIds = Set(tabManager.tabIds) coordinator.cleanupTabCaches(openTabIds: openTabIds) erDiagramViewModels = erDiagramViewModels.filter { openTabIds.contains($0.key) } @@ -165,7 +176,7 @@ struct MainEditorContentView: View { queryInsightsViewModels = queryInsightsViewModels.filter { openTabIds.contains($0.key) } SchemaProviderRegistry.shared.reclaimUnheldProviders(for: connectionId) } - .onChange(of: tabManager.selectedTabId) { _, _ in + .onChange(of: tabManager.selectedTabId) { _ in updateHasQueryText() } .onAppear { @@ -184,19 +195,19 @@ struct MainEditorContentView: View { )) { coordinator.lazyLoadCurrentTabIfNeeded() } - .onChange(of: selectionState.indices) { _, newIndices in + .onChange(of: selectionState.indices) { newIndices in onSelectionChange(newIndices) } - .onChange(of: tabManager.selectedTab?.tableContext.isEditable) { _, _ in + .onChange(of: tabManager.selectedTab?.tableContext.isEditable) { _ in refreshDataTabDelegateMutableRefs() } - .onChange(of: tabManager.selectedTab?.tableContext.isView) { _, _ in + .onChange(of: tabManager.selectedTab?.tableContext.isView) { _ in refreshDataTabDelegateMutableRefs() } - .onChange(of: tabManager.selectedTab?.tableContext.tableName) { _, _ in + .onChange(of: tabManager.selectedTab?.tableContext.tableName) { _ in refreshDataTabDelegateMutableRefs() } - .onChange(of: coordinator.safeModeLevel) { _, _ in + .onChange(of: coordinator.safeModeLevel) { _ in refreshDataTabDelegateMutableRefs() } } @@ -256,7 +267,7 @@ struct MainEditorContentView: View { ) .id(objectRef) } else { - ContentUnavailableView( + UnavailableStateView( String(localized: "No Object"), systemImage: "questionmark.square.dashed" ) @@ -410,7 +421,6 @@ struct MainEditorContentView: View { @ViewBuilder private func queryTabContent(tab: QueryTab) -> some View { - @Bindable var bindableCoordinator = coordinator let claimFocus = coordinator.tabManager.pendingFocusTabId == tab.id let queryScope = coordinator.scope(for: tab) VerticalCollapsibleSplitView( @@ -434,7 +444,7 @@ struct MainEditorContentView: View { } QueryEditorView( queryText: queryTextBinding(for: tab), - cursorPositions: $bindableCoordinator.cursorPositions, + cursorPositions: $coordinator.cursorPositions, parameters: parameterBinding(for: tab), isParameterPanelVisible: parameterVisibilityBinding(for: tab), schemaProvider: queryScope.map { SchemaProviderRegistry.shared.getOrCreate(for: $0) }, @@ -589,7 +599,7 @@ struct MainEditorContentView: View { @ViewBuilder private func tableTabContent(tab: QueryTab) -> some View { VStack(spacing: 0) { - if tab.isPreview { + if tab.isPreview, #available(macOS 14.0, *) { FeatureTipInline(tip: KeepTableOpenTip()) } resultsSection(tab: tab) @@ -817,7 +827,7 @@ struct MainEditorContentView: View { } private func unavailableModeView(_ mode: ResultsViewMode) -> some View { - ContentUnavailableView( + UnavailableStateView( String(localized: "No Data"), systemImage: mode == .map ? "map" : "chart.bar.xaxis", description: Text(mode == .map @@ -907,7 +917,7 @@ struct MainEditorContentView: View { private func emptyResultView(executionTime: TimeInterval?) -> some View { let description: String? = executionTime.map { String(format: "%.3fs", $0) } - return ContentUnavailableView { + return UnavailableStateView { Label(String(localized: "No rows returned"), systemImage: "tray") } description: { if let description { diff --git a/TablePro/Views/Main/EditorTabStrip.swift b/TablePro/Views/Main/EditorTabStrip.swift index 8388aa35dd..1828e753f9 100644 --- a/TablePro/Views/Main/EditorTabStrip.swift +++ b/TablePro/Views/Main/EditorTabStrip.swift @@ -31,10 +31,10 @@ import SwiftUI /// elements" (WWDC25 session 219). The band is already the system's glass, so these fills are the /// top layer on it rather than a second pane of it. internal struct EditorTabStrip: View { - internal let tabManager: QueryTabManager + @ObservedObject internal var tabManager: QueryTabManager /// The pointer's owner. AppKit measures the run and drives every press; this view draws what /// that produced. Nothing here reads a mouse. - internal let interaction: EditorTabStripInteraction + @ObservedObject internal var interaction: EditorTabStripInteraction /// The dimension this engine's tabs are anchored to, so a label can name the container it /// shares a title with. Resolved by the window, because a view has no business asking the /// plugin registry what kind of container a connection has. @@ -56,7 +56,7 @@ internal struct EditorTabStrip: View { /// Read here rather than pushed in at build time, so changing the preference re-lays every /// open strip at once instead of the next time an unrelated pane happens to rebuild. - @State private var settings = AppSettingsManager.shared + @ObservedObject private var settings = AppSettingsManager.shared @Environment(\.controlActiveState) private var controlActiveState @Environment(\.colorSchemeContrast) private var colorSchemeContrast @@ -79,15 +79,19 @@ internal struct EditorTabStrip: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) /// A closed tab leaves its id behind, and the tab that slides into its place would /// otherwise light up under a pointer that never moved onto it. - .onChange(of: tabManager.tabs.map(\.id), initial: true) { _, ids in + .onAppear { + interaction.dropClosedTabs(keeping: tabManager.tabs.map(\.id)) + interaction.overflow = settings.tabs.overflow + } + .onChange(of: tabManager.tabs.map(\.id)) { ids in interaction.dropClosedTabs(keeping: ids) } - .onChange(of: settings.tabs.overflow, initial: true) { _, style in + .onChange(of: settings.tabs.overflow) { style in interaction.overflow = style } /// Cmd+1..9, opening a table from the sidebar and closing a tab can all land on a tab that /// is scrolled out of sight, so the selection pulls itself into view. - .onChange(of: tabManager.selectedTabId) { _, newValue in + .onChange(of: tabManager.selectedTabId) { newValue in guard let newValue else { return } withMotion(.easeOut(duration: 0.15)) { interaction.revealTab(id: newValue) @@ -95,7 +99,7 @@ internal struct EditorTabStrip: View { } .accessibilityElement(children: .contain) .accessibilityLabel(Text("Editor Tabs")) - .accessibilityAddTraits(.isTabBar) + .modifier(TabBarAccessibilityTrait()) } private var trackHeight: CGFloat { @@ -448,8 +452,8 @@ private struct EditorTabStripCloseButtonStyle: ButtonStyle { } private func fill(isPressed: Bool) -> Color { - if isPressed { return Color(nsColor: .tertiarySystemFill) } - return isHovering ? Color(nsColor: .quaternarySystemFill) : .clear + if isPressed { return Color(nsColor: .tertiaryFill) } + return isHovering ? Color(nsColor: .quaternaryFill) : .clear } } @@ -565,3 +569,16 @@ private extension View { } } } + +/// `AccessibilityTraits.isTabBar` is macOS 14. Without it VoiceOver announces the strip as a +/// plain container; the label and the per-tab elements are unchanged. +private struct TabBarAccessibilityTrait: ViewModifier { + @ViewBuilder + func body(content: Content) -> some View { + if #available(macOS 14.0, *) { + content.accessibilityAddTraits(.isTabBar) + } else { + content + } + } +} diff --git a/TablePro/Views/Main/EditorTabStripInteraction.swift b/TablePro/Views/Main/EditorTabStripInteraction.swift index 0543cc506e..176eefd5ee 100644 --- a/TablePro/Views/Main/EditorTabStripInteraction.swift +++ b/TablePro/Views/Main/EditorTabStripInteraction.swift @@ -4,8 +4,8 @@ // import AppKit +import Combine import Foundation -import Observation /// What a press on the strip turned out to be. internal enum EditorTabGesture: Equatable { @@ -28,22 +28,21 @@ internal enum EditorTabGesture: Equatable { /// the tab the pointer hits and the tab a drag targets are one rectangle out of /// `EditorTabRunLayout`, so they cannot disagree. @MainActor -@Observable -internal final class EditorTabStripInteraction { +internal final class EditorTabStripInteraction: ObservableObject { /// The run the strip draws, measured by the view that owns the pointer. - internal private(set) var run: EditorTabRunLayout = .empty + @Published internal private(set) var run: EditorTabRunLayout = .empty /// How far the track has scrolled, in points. Always zero when the run wraps, because a /// wrapped run never overflows. - internal private(set) var contentOffset: CGFloat = 0 - internal private(set) var hoveredTabId: UUID? + @Published internal private(set) var contentOffset: CGFloat = 0 + @Published internal private(set) var hoveredTabId: UUID? /// Set while the pointer is over a tab's close button, because the button no longer receives /// the mouse and cannot light itself. - internal private(set) var hoveredCloseTabId: UUID? + @Published internal private(set) var hoveredCloseTabId: UUID? /// The reorder in flight, holding both the order the strip draws and the order it came from. /// The manager is not written until the pointer comes up, so an abandoned drag leaves nothing /// behind and Escape is just dropping this value. - internal private(set) var reorder: EditorTabReorder? - internal private(set) var tearingOffTabId: UUID? + @Published internal private(set) var reorder: EditorTabReorder? + @Published internal private(set) var tearingOffTabId: UUID? /// The track's visible width, measured by the view that owns the pointer. SwiftUI reads it so /// a reveal and a clamp use the same number the pointer does. internal private(set) var viewportWidth: CGFloat = 0 @@ -61,15 +60,15 @@ internal final class EditorTabStripInteraction { /// Rebuilt on every render, because the closures reach through the workspace to a coordinator /// that only exists once the detail pane has appeared. - internal var commands: EditorTabCommands? + @Published internal var commands: EditorTabCommands? - internal var tabIds: [UUID] = [] + @Published internal var tabIds: [UUID] = [] /// Raised whenever a rebuild changes how many rows the run takes, from any path. The band's /// height follows it, and a tab opened or closed while the strip is wrapped can cross a row /// boundary without any layout pass having run. - internal var onRowCountChanged: ((Int) -> Void)? - private var reportedRowCount = 1 + @Published internal var onRowCountChanged: ((Int) -> Void)? + @Published private var reportedRowCount = 1 /// The order the strip draws: the reorder's while one is in flight, the manager's otherwise. internal var displayedIds: [UUID] { diff --git a/TablePro/Views/Main/EditorTabStripSurfaces.swift b/TablePro/Views/Main/EditorTabStripSurfaces.swift index 09431c925d..a07dc6295d 100644 --- a/TablePro/Views/Main/EditorTabStripSurfaces.swift +++ b/TablePro/Views/Main/EditorTabStripSurfaces.swift @@ -35,7 +35,7 @@ internal enum EditorTabStripPalette { /// The system darkens a hovered tab rather than lightening it, in light appearance: a rendered /// `NSTabBar` measures rgb(220) under the pointer against a rgb(232) track, and this resolves /// to rgb(210) against rgb(220). The direction is deliberate and matches, so it is left alone. - internal static var hoverFill: Color { Color(nsColor: .tertiarySystemFill) } + internal static var hoverFill: Color { Color(nsColor: .tertiaryFill) } internal static var separator: Color { Color(nsColor: .separatorColor) } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+History.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+History.swift index 665df3427a..5415915038 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+History.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+History.swift @@ -7,7 +7,9 @@ extension MainContentCoordinator { @discardableResult func recordHistory(_ request: QueryHistoryRecordRequest) -> Task { if request.source == .editor { - FeatureTipSignals.editorQueryRan() + if #available(macOS 14.0, *) { + FeatureTipSignals.editorQueryRan() + } } let recorder = services.queryHistoryManager return Task(priority: .utility) { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 0e302cd62e..911f7119a5 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -353,7 +353,9 @@ extension MainContentCoordinator { } lazyLoadCurrentTabIfNeeded() if replacesPreviewTab, createAsPreview { - FeatureTipSignals.previewTabReplaced() + if #available(macOS 14.0, *) { + FeatureTipSignals.previewTabReplaced() + } } return true } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift index 077f74ed3d..f1d7673192 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift @@ -20,9 +20,9 @@ extension MainContentCoordinator { /// talking over whatever the front one is doing. func announceQueryError(_ message: String) { guard contentWindow?.isKeyWindow == true else { return } - AccessibilityNotification.Announcement( + AccessibilityAnnouncement.post( String(format: String(localized: "Query failed. %@"), message) - ).post() + ) } /// A table tab's SELECT is the app's own, so it may follow a database switch it waited through diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift index 828d36be9e..3294a4bd9c 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift @@ -19,7 +19,9 @@ extension MainContentCoordinator { quickSwitcherPanel.dismiss() return } - FeatureTipSignals.quickSwitcherOpened() + if #available(macOS 14.0, *) { + FeatureTipSignals.quickSwitcherOpened() + } let browseSchema = services.databaseManager.session(for: connectionId)?.browseSchema let switcherScope = browseScope ?? DatabaseScope(connectionId: connectionId, database: connection.database, schema: nil) diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 23723a67e6..a8401ddd37 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -10,7 +10,6 @@ import AppKit import Combine import Foundation -import Observation import os import SwiftUI import TableProPluginKit @@ -18,8 +17,7 @@ import UniformTypeIdentifiers /// Provides command actions for MainContentView, reached through `MainContentCoordinator.commandActions`. @MainActor -@Observable -final class MainContentCommandActions { +final class MainContentCommandActions: ObservableObject { nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "MainContentCommandActions") enum WindowCloseOutcome { @@ -29,20 +27,20 @@ final class MainContentCommandActions { // MARK: - Dependencies - @ObservationIgnored internal weak var coordinator: MainContentCoordinator? - @ObservationIgnored private let connection: DatabaseConnection + internal weak var coordinator: MainContentCoordinator? + private let connection: DatabaseConnection // MARK: - Bindings - @ObservationIgnored private let selectionState: GridSelectionState - @ObservationIgnored private let selectedTables: Binding> - @ObservationIgnored private let pendingTruncates: Binding> - @ObservationIgnored private let pendingDeletes: Binding> - @ObservationIgnored private let tableOperationOptions: Binding<[DatabaseTreeTableRef: TableOperationOptions]> - @ObservationIgnored private let trailingPaneState: TrailingPaneState + private let selectionState: GridSelectionState + private let selectedTables: Binding> + private let pendingTruncates: Binding> + private let pendingDeletes: Binding> + private let tableOperationOptions: Binding<[DatabaseTreeTableRef: TableOperationOptions]> + private let trailingPaneState: TrailingPaneState /// The window this instance belongs to — used for key-window guards. - @ObservationIgnored weak var window: NSWindow? { + weak var window: NSWindow? { didSet { guard window !== oldValue else { return } updateTextInputFocusTracking() @@ -54,17 +52,17 @@ final class MainContentCommandActions { /// Whether a text input holds first responder in this instance's window. /// Stored rather than computed so Observation wakes the menu when focus /// crosses that boundary; `NSWindow.firstResponder` publishes no change. - var focusOwnsTextInput = false + @Published var focusOwnsTextInput = false - @ObservationIgnored let textInputFocusObserver = OSAllocatedUnfairLock<(any NSObjectProtocol)?>(uncheckedState: nil) + let textInputFocusObserver = OSAllocatedUnfairLock<(any NSObjectProtocol)?>(uncheckedState: nil) - @ObservationIgnored var isTextInputFocusCheckScheduled = false + var isTextInputFocusCheckScheduled = false /// Task handles for async notification observers; cancelled on deinit. - @ObservationIgnored private var notificationTasks: [Task] = [] + private var notificationTasks: [Task] = [] /// Combine subscriptions for typed AppEvents publishers. - @ObservationIgnored private var eventCancellables: Set = [] + private var eventCancellables: Set = [] // MARK: - Initialization diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 97bb12fed8..b6bb44c19a 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -8,7 +8,6 @@ import Combine import Foundation -import Observation import os import SwiftUI import TableProEditorKit @@ -118,8 +117,8 @@ enum ActiveSheet: Identifiable { } /// Coordinator managing MainContentView business logic -@MainActor @Observable -final class MainContentCoordinator { +@MainActor +final class MainContentCoordinator: ObservableObject { nonisolated static let logger = Logger(subsystem: "com.TablePro", category: "MainContentCoordinator") nonisolated static let lifecycleLogger = Logger(subsystem: "com.TablePro", category: "NativeTabLifecycle") @@ -132,7 +131,7 @@ final class MainContentCoordinator { // MARK: - Dependencies - @ObservationIgnored let services: AppServices + let services: AppServices let connection: DatabaseConnection var connectionId: UUID { connection.id } var sqlDialect: SqlDialect { SqlDialect.from(databaseTypeId: connection.type.rawValue) } @@ -154,27 +153,27 @@ final class MainContentCoordinator { let windowSidebarState: WindowSidebarState /// Which tab each of this connection's containers was last on, so the connections strip lands /// on that container's work instead of leaving a tab from another database on screen. - @ObservationIgnored internal var containerTabHistory = ContainerTabHistory() + internal var containerTabHistory = ContainerTabHistory() // MARK: - Services - internal var queryBuilder: TableQueryBuilder + @Published internal var queryBuilder: TableQueryBuilder let persistence: TabPersistenceCoordinator - @ObservationIgnored internal lazy var rowOperationsManager: RowOperationsManager = { + internal lazy var rowOperationsManager: RowOperationsManager = { RowOperationsManager(changeManager: changeManager) }() - @ObservationIgnored private(set) var filterCoordinator: FilterCoordinator! - @ObservationIgnored private(set) var findCoordinator: FindCoordinator! - @ObservationIgnored private(set) var queryExecutionCoordinator: QueryExecutionCoordinator! - @ObservationIgnored private(set) var paginationCoordinator: PaginationCoordinator! - @ObservationIgnored private(set) var rowEditingCoordinator: RowEditingCoordinator! + private(set) var filterCoordinator: FilterCoordinator! + private(set) var findCoordinator: FindCoordinator! + private(set) var queryExecutionCoordinator: QueryExecutionCoordinator! + private(set) var paginationCoordinator: PaginationCoordinator! + private(set) var rowEditingCoordinator: RowEditingCoordinator! /// Stable identifier for this coordinator's window (set by MainContentView on appear) - var windowId: UUID? + @Published var windowId: UUID? /// Setting this presents the favorite-edit dialog sheet from `MainEditorContentView`. - var favoriteDialogQuery: FavoriteDialogQuery? + @Published var favoriteDialogQuery: FavoriteDialogQuery? /// Direct reference to sidebar viewmodel, eliminates global notification broadcasts weak var sidebarViewModel: SidebarViewModel? @@ -186,7 +185,7 @@ final class MainContentCoordinator { /// Each apply broadcasts a data refresh for its scope, and a mounted structure view on the same /// database answers that by asking whether to discard its own staged edits, which mid-close is /// a question the user cannot usefully answer. Scoped by the caller's `defer`, never latched. - var isApplyingStagedStructureEdits = false + @Published var isApplyingStagedStructureEdits = false /// Direct reference to create-table view actions so the Save Changes menu /// (Cmd+S) routes to table creation. Set by `CreateTableView` on appear. @@ -198,18 +197,18 @@ final class MainContentCoordinator { /// for two reasons: the view is destroyed on every tab switch and on every switch between Data /// and Structure, and the close gate has to be able to see the staged work of a tab the user is /// not currently looking at. - var structureSessions: [UUID: StructureEditingSession] = [:] - var createTableDrafts: [UUID: CreateTableDraft] = [:] + @Published var structureSessions: [UUID: StructureEditingSession] = [:] + @Published var createTableDrafts: [UUID: CreateTableDraft] = [:] /// Tabs holding staged principal changes. `usersRolesActions` is nilled the moment the tab is /// deselected, but the view model behind it is cached per tab id and keeps the staged work, so /// without this record a background Users & Roles tab reports itself clean and closes silently. - @ObservationIgnored internal var tabsWithStagedPrincipals: Set = [] + internal var tabsWithStagedPrincipals: Set = [] /// Tabs whose close confirmation is already on screen. `saveCompletionContinuation` is a single /// slot, so a second gesture arriving before the first sheet resolves would overwrite the /// continuation the first one is suspended on and leave that task waiting forever. - @ObservationIgnored internal var tabClosesInFlight: Set = [] + internal var tabClosesInFlight: Set = [] /// The grid that owns the current selection when it is not the data grid, so the /// inspector reads the selected row from it instead of the data tab's rows. @@ -217,7 +216,7 @@ final class MainContentCoordinator { weak var inspectorRowSource: (any InspectorRowSource)? /// Bumped whenever a published schema row changes, so the inspector re-reads it. - var inspectorRowSourceRevision: Int = 0 + @Published var inspectorRowSourceRevision: Int = 0 /// Direct reference to AI chat viewmodel — eliminates notification broadcasts /// The assistant's view model, and only if something has already brought one into existence. @@ -231,7 +230,7 @@ final class MainContentCoordinator { /// Observable mirror of the grid's display revision, so views outside the grid re-render when /// the value filter or the displayed order changes. The grid's own state lives on a plain /// AppKit object reached through observation-ignored hops, so it cannot invalidate a view. - var gridDisplayRevision: Int = 0 + @Published var gridDisplayRevision: Int = 0 /// Bumped 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 @@ -239,7 +238,7 @@ final class MainContentCoordinator { var inspectorRowContentRevision: Int = 0 /// dispatch insertRows/removeRows directly to the NSTableView via DataGridViewDelegate. - @ObservationIgnored weak var dataTabDelegate: DataTabGridDelegate? + weak var dataTabDelegate: DataTabGridDelegate? var activeGridDisplayIDs: [RowID]? { guard let tabId = tabManager.selectedTab?.id else { return nil } @@ -248,22 +247,22 @@ final class MainContentCoordinator { /// One-shot intent set when the user explicitly opens a table (Return/double-click), /// consumed by the grid as it appears to move focus into it. Never set on mere selection. - @ObservationIgnored var pendingGridFocusOnOpen = false + var pendingGridFocusOnOpen = false /// Proxy for toggling the inspector NSSplitViewItem from coordinator code - @ObservationIgnored weak var trailingPaneProxy: TrailingPaneProxy? + weak var trailingPaneProxy: TrailingPaneProxy? /// Direct reference to split view controller for sidebar toggle - @ObservationIgnored weak var splitViewController: MainSplitViewController? + weak var splitViewController: MainSplitViewController? /// Direct reference to this coordinator's content window, used for presenting alerts. /// Avoids NSApp.keyWindow which may return a sheet window, causing stuck dialogs. - @ObservationIgnored weak var contentWindow: NSWindow? + weak var contentWindow: NSWindow? /// Back-reference to this coordinator's command actions, enabling window → coordinator → actions /// lookup. The app runs the AppKit lifecycle with no SwiftUI `Scene`, so a focused value has /// nothing to resolve against; this reference reaches every caller, AppKit and SwiftUI alike. - @ObservationIgnored weak var commandActions: MainContentCommandActions? + weak var commandActions: MainContentCommandActions? /// Presents the quick switcher as a floating panel anchored over this coordinator's window. /// The window owns it, because the panel anchors on the window and every connection the window @@ -274,9 +273,9 @@ final class MainContentCoordinator { // MARK: - Published State - var cursorPositions: [CursorPosition] = [] - var tableMetadata: TableMetadata? - var activeSheet: ActiveSheet? + @Published var cursorPositions: [CursorPosition] = [] + @Published var tableMetadata: TableMetadata? + @Published var activeSheet: ActiveSheet? /// Owns the connection and database switcher surfaces. The commands present through this /// rather than flipping a flag a toolbar-hosted view has to observe, because that view is /// absent whenever its item is clipped into the overflow menu or removed by the user. It @@ -284,37 +283,37 @@ final class MainContentCoordinator { var switcherPresenter: ToolbarSwitcherPresenter? { splitViewController?.switcherPresenter } - var sessionContexts: [PluginSessionContext] = [] - var containerDropRequest: DatabaseDropRequest? - var importFileURL: URL? - var exportPreselection: ExportPreselection? - var pendingLoadTrigger: TableLoadTrigger? - @ObservationIgnored var deferredRestoreLoadTabId: UUID? + @Published var sessionContexts: [PluginSessionContext] = [] + @Published var containerDropRequest: DatabaseDropRequest? + @Published var importFileURL: URL? + @Published var exportPreselection: ExportPreselection? + @Published var pendingLoadTrigger: TableLoadTrigger? + var deferredRestoreLoadTabId: UUID? - @ObservationIgnored var displayFormatsCache: [UUID: DisplayFormatsCacheEntry] = [:] - @ObservationIgnored var displayOrderCache: [UUID: DisplayOrderCacheEntry] = [:] - @ObservationIgnored var displayStateCache: [UUID: DisplayStateCacheEntry] = [:] - @ObservationIgnored var tableMetadataCache: [UUID: TableMetadataCacheEntry] = [:] - @ObservationIgnored var displayStateClock = 0 + var displayFormatsCache: [UUID: DisplayFormatsCacheEntry] = [:] + var displayOrderCache: [UUID: DisplayOrderCacheEntry] = [:] + var displayStateCache: [UUID: DisplayStateCacheEntry] = [:] + var tableMetadataCache: [UUID: TableMetadataCacheEntry] = [:] + var displayStateClock = 0 - @ObservationIgnored let schemaColumns = SchemaColumnStore() - @ObservationIgnored var columnScopeRequeryTask: Task? + let schemaColumns = SchemaColumnStore() + var columnScopeRequeryTask: Task? - @ObservationIgnored var openTabInNewWindow: (EditorTabPayload) -> Void = { + var openTabInNewWindow: (EditorTabPayload) -> Void = { WindowManager.shared.openTab(payload: $0) } - @ObservationIgnored var connectionExists: (UUID) -> Bool = { id in + var connectionExists: (UUID) -> Bool = { id in ConnectionStorage.shared.loadConnections().contains { $0.id == id } } - @ObservationIgnored var hostedTabRouting = HostedTabRouting.live + var hostedTabRouting = HostedTabRouting.live /// Routing failures report through here so a test can observe the message instead of raising a /// real alert. `AlertHelper.present` runs application-modal when no window qualifies, and a /// unit test host has no window, so calling it directly parks the main thread in a modal loop /// that nothing can dismiss and no test time limit can interrupt. - @ObservationIgnored var presentError: (String, String, NSWindow?) -> Void = { title, message, window in + var presentError: (String, String, NSWindow?) -> Void = { title, message, window in AlertHelper.showErrorSheet(title: title, message: message, window: window) } @@ -323,39 +322,39 @@ final class MainContentCoordinator { /// Per-tab execution ownership. Replaces a per-window generation counter, a stored per-tab /// `isExecuting` bool and two task handles that a tab retarget participated in none of. /// - /// Deliberately observed rather than `@ObservationIgnored`: busy state is derived from + /// Deliberately observed rather than ``: busy state is derived from /// membership here, so the views that used to read the stored flag have to be able to see it /// change. It is a value type, so every claim, settle and invalidate is a write to this /// property and invalidates its readers. - internal var tabExecution = TabExecutionRegistry() - @ObservationIgnored internal var currentQueryTask: Task? + @Published internal var tabExecution = TabExecutionRegistry() + internal var currentQueryTask: Task? /// Which claim installed `currentQueryTask`. The handle is one per window while claims are one /// per tab, so owning your own tab is not the same as owning the query the window is running: /// superseding tab B cancels tab A's task, and A's completion would otherwise nil out B's /// handle and leave B's query with no spinner and no way to stop it. - @ObservationIgnored internal var currentQueryTaskOwner: TabExecutionClaim? - @ObservationIgnored internal var rowCountTasks: [UUID: (token: UUID, task: Task)] = [:] + internal var currentQueryTaskOwner: TabExecutionClaim? + internal var rowCountTasks: [UUID: (token: UUID, task: Task)] = [:] /// Which user-requested exact count currently owns each tab's counting indicator. - @ObservationIgnored internal var exactCountOwners: [UUID: UUID] = [:] - @ObservationIgnored internal var tableLoadTasks: [UUID: (token: UUID, task: Task)] = [:] + internal var exactCountOwners: [UUID: UUID] = [:] + internal var tableLoadTasks: [UUID: (token: UUID, task: Task)] = [:] /// Each tab's browse history, keyed by tab id the way the other per-tab caches here are. /// /// Not a field on `QueryTab`: that struct is the persisted shape of a tab, and an entry /// describes rows that may be gone by the next launch. Keeping it out of the struct also keeps /// it out of the hand-written `Equatable`, so a push never re-publishes the tab list. - @ObservationIgnored internal var navigationHistories: [UUID: TabNavigationHistory] = [:] - @ObservationIgnored internal var redisDatabaseSwitchTask: Task? - @ObservationIgnored private var periodicSaveTask: Task? - @ObservationIgnored private var draftSaveTask: Task? - @ObservationIgnored private var terminationObserver: NSObjectProtocol? - @ObservationIgnored internal var postConnectCancellable: AnyCancellable? - @ObservationIgnored private var externalFileModCancellable: AnyCancellable? - @ObservationIgnored private var schemaSwitchCancellable: AnyCancellable? + internal var navigationHistories: [UUID: TabNavigationHistory] = [:] + internal var redisDatabaseSwitchTask: Task? + private var periodicSaveTask: Task? + private var draftSaveTask: Task? + private var terminationObserver: NSObjectProtocol? + internal var postConnectCancellable: AnyCancellable? + private var externalFileModCancellable: AnyCancellable? + private var schemaSwitchCancellable: AnyCancellable? - var fileConflictRequest: FileConflictRequest? + @Published var fileConflictRequest: FileConflictRequest? struct FileConflictRequest: Identifiable { let id = UUID() @@ -364,52 +363,52 @@ final class MainContentCoordinator { let mineContent: String let diskContent: String } - @ObservationIgnored private var fileWatcher: DatabaseFileWatcher? + private var fileWatcher: DatabaseFileWatcher? /// Set during handleTabChange to suppress redundant column-change reconfiguration - @ObservationIgnored internal var isHandlingTabSwitch = false - @ObservationIgnored var isUpdatingColumnLayout = false + internal var isHandlingTabSwitch = false + var isUpdatingColumnLayout = false /// Guards against re-entrant confirm dialogs (e.g. nested run loop during runModal) - @ObservationIgnored internal var isShowingConfirmAlert = false + internal var isShowingConfirmAlert = false /// Guards against duplicate safe mode confirmation prompts - @ObservationIgnored internal var isShowingSafeModePrompt = false + internal var isShowingSafeModePrompt = false /// What restoring the last save would do, once it has been planned against the live rows. - internal var rewindPlan: RewindPlan? + @Published internal var rewindPlan: RewindPlan? /// The rebuild a column drag asked for, held while the user reads it. - internal var tableRebuildRequest: TableRebuildReviewRequest? + @Published internal var tableRebuildRequest: TableRebuildReviewRequest? /// Continuation for callers that need to await the result of a fire-and-forget save /// (e.g. save-then-close). Set before calling `saveChanges`, resumed by `executeCommitStatements`. - @ObservationIgnored internal var saveCompletionContinuation: CheckedContinuation? + internal var saveCompletionContinuation: CheckedContinuation? // MARK: - Window Lifecycle (driven by TabWindowController NSWindowDelegate) /// Whether this coordinator's window is the key (focused) window. /// Updated by TabWindowController delegate methods; consumed by /// event handlers (e.g. sidebar table-selection navigation filter). - @ObservationIgnored var isKeyWindow = false + var isKeyWindow = false /// Eviction task scheduled in `handleWindowDidResignKey` (fires 5s later). - @ObservationIgnored var evictionTask: Task? + var evictionTask: Task? - @ObservationIgnored var refreshCoalesceTask: Task? - @ObservationIgnored var refreshPendingTrailing = false + var refreshCoalesceTask: Task? + var refreshPendingTrailing = false /// True once the coordinator's view has appeared (onAppear fired). /// Coordinators that SwiftUI creates during body re-evaluation but never /// adopts into @State are silently discarded — no teardown warning needed. - @ObservationIgnored private let _didActivate = OSAllocatedUnfairLock(initialState: false) + private let _didActivate = OSAllocatedUnfairLock(initialState: false) /// Tracks whether teardown() was called; used by deinit to log missed teardowns - @ObservationIgnored private let _didTeardown = OSAllocatedUnfairLock(initialState: false) + private let _didTeardown = OSAllocatedUnfairLock(initialState: false) /// Tracks whether teardown has been scheduled (but not yet executed) /// so deinit doesn't warn if SwiftUI deallocates before the delayed Task fires - @ObservationIgnored private let _teardownScheduled = OSAllocatedUnfairLock(initialState: false) + private let _teardownScheduled = OSAllocatedUnfairLock(initialState: false) /// Whether teardown is scheduled or already completed — used by views to skip /// persistence during window close teardown diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 5a5828927e..0a760b9081 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -31,12 +31,12 @@ struct MainContentView: View { // Shared state from parent @Binding var windowTitle: String @Binding var windowSubtitle: String - @Bindable var schemaService = SchemaService.shared - var sidebarState: SharedSidebarState + @ObservedObject var schemaService = SchemaService.shared + @ObservedObject var sidebarState: SharedSidebarState @Binding var pendingTruncates: Set @Binding var pendingDeletes: Set @Binding var tableOperationOptions: [DatabaseTreeTableRef: TableOperationOptions] - var trailingPaneState: TrailingPaneState + @ObservedObject var trailingPaneState: TrailingPaneState var tables: [TableInfo] { schemaService.tables(for: connection.id) @@ -44,10 +44,10 @@ struct MainContentView: View { // MARK: - State Objects - let tabManager: QueryTabManager - let changeManager: DataChangeManager - let toolbarState: ConnectionToolbarState - let coordinator: MainContentCoordinator + @ObservedObject var tabManager: QueryTabManager + @ObservedObject var changeManager: DataChangeManager + @ObservedObject var toolbarState: ConnectionToolbarState + @ObservedObject var coordinator: MainContentCoordinator // MARK: - Local State @@ -100,7 +100,7 @@ struct MainContentView: View { var body: some View { bodyContent - .sheet(item: Bindable(coordinator).activeSheet) { sheet in + .sheet(item: $coordinator.activeSheet) { sheet in sheetContent(for: sheet) } .confirmationDialog( @@ -333,20 +333,20 @@ struct MainContentView: View { .task(id: coordinator.toolbarState.connectionState) { await coordinator.loadSessionContexts() } - .onChange(of: inspectorTrigger) { + .onChange(of: inspectorTrigger) { _ in scheduleInspectorUpdate() } /// The JSON rendering draws the snapshot the context carries, and an edit made in the /// fields rendering changes the row under it without moving anything `InspectorTrigger` /// watches. Rebuilding on the switch is enough: the two renderings are never on screen /// together, so the stale snapshot is only ever reached by switching to it. - .onChange(of: trailingPaneState.inspector.viewMode) { + .onChange(of: trailingPaneState.inspector.viewMode) { _ in updateInspectorContext() } /// 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) { + .onChange(of: coordinator.inspectorRowContentRevision) { _ in scheduleInspectorContextRefresh() } .onAppear { @@ -364,10 +364,10 @@ struct MainContentView: View { "[open] MainContentView.onAppear done windowId=\(windowId, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(start) * 1_000))" ) } - .onChange(of: trailingPaneState.assistant.isActivated) { + .onChange(of: trailingPaneState.assistant.isActivated) { _ in updateAssistantContext() } - .onChange(of: pendingChangeTrigger) { + .onChange(of: pendingChangeTrigger) { _ in updateToolbarPendingState() } } @@ -384,7 +384,7 @@ struct MainContentView: View { "[open] bodyContentCore.task initializeAndRestoreTabs done windowId=\(windowId, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(start) * 1_000))" ) } - .onChange(of: tabManager.selectedTabId) { oldTabId, newTabId in + .onValueChange(of: tabManager.selectedTabId) { oldTabId, newTabId in guard !coordinator.isTearingDown else { Self.lifecycleLogger.debug("[switch] selectedTabId SKIPPED (tearingDown) to=\(newTabId?.uuidString ?? "nil", privacy: .public) windowId=\(windowId, privacy: .public)") return @@ -400,10 +400,10 @@ struct MainContentView: View { (viewWindow?.windowController as? TabWindowController)?.refreshUserActivity() handleTabSelectionChange(from: oldTabId, to: newTabId) } - .onChange(of: tabManager.tabStructureVersion) { _, _ in + .onChange(of: tabManager.tabStructureVersion) { _ in handleStructureChange() } - .onChange(of: currentTab?.schemaVersion) { _, _ in + .onChange(of: currentTab?.schemaVersion) { _ in let columns = currentTab.map { coordinator.tabSessionRegistry.tableRows(for: $0.id).columns } handleColumnsChange(newColumns: columns) } @@ -415,7 +415,7 @@ struct MainContentView: View { handleConnectionStatusChange() } - .onChange(of: coordinator.windowSidebarState.selectedTables) { oldTables, newTables in + .onValueChange(of: coordinator.windowSidebarState.selectedTables) { oldTables, newTables in guard !coordinator.isTearingDown else { Self.lifecycleLogger.debug("[switch] windowSidebarState.selectedTables SKIPPED (tearingDown) windowId=\(windowId, privacy: .public)") return @@ -426,7 +426,7 @@ struct MainContentView: View { /// the user. Every other input re-asserts unconditionally: a container switch above all, /// because a selection made in the container being left is not one in the container /// arriving. - .onChange(of: tables) { _, _ in + .onChange(of: tables) { _ in guard coordinator.windowSidebarState.acceptsObjectMarkRefresh else { return } coordinator.syncSidebarObjectSelection() } @@ -439,6 +439,7 @@ struct MainContentView: View { MainEditorContentView( tabManager: tabManager, coordinator: coordinator, + historyState: HistoryPanelState.forConnection(connection.id), changeManager: changeManager, connection: connection, windowId: windowId, diff --git a/TablePro/Views/ObjectCopy/CopyObjectsConfigureView.swift b/TablePro/Views/ObjectCopy/CopyObjectsConfigureView.swift index 17875ef37f..7601e31dc6 100644 --- a/TablePro/Views/ObjectCopy/CopyObjectsConfigureView.swift +++ b/TablePro/Views/ObjectCopy/CopyObjectsConfigureView.swift @@ -8,7 +8,7 @@ import SwiftUI internal struct CopyObjectsConfigureView: View { - @Bindable internal var session: ObjectCopySession + @ObservedObject internal var session: ObjectCopySession @Binding internal var isChoosingTarget: Bool internal var body: some View { diff --git a/TablePro/Views/ObjectCopy/CopyObjectsListView.swift b/TablePro/Views/ObjectCopy/CopyObjectsListView.swift index 4397c0304c..e4fef8ce31 100644 --- a/TablePro/Views/ObjectCopy/CopyObjectsListView.swift +++ b/TablePro/Views/ObjectCopy/CopyObjectsListView.swift @@ -9,7 +9,7 @@ import SwiftUI import TableProPluginKit internal struct CopyObjectsListView: View { - @Bindable internal var session: ObjectCopySession + @ObservedObject internal var session: ObjectCopySession internal var body: some View { VStack(spacing: 0) { @@ -73,7 +73,7 @@ internal struct CopyObjectsListView: View { .foregroundStyle(.secondary) } } else if let message = session.catalogError { - ContentUnavailableView { + UnavailableStateView { Label("Cannot Read the Source", systemImage: "exclamationmark.triangle") } description: { RevealedTextView(message) @@ -81,7 +81,7 @@ internal struct CopyObjectsListView: View { Button("Try Again") { Task { await session.loadObjects() } } } } else if session.filteredObjects.isEmpty { - ContentUnavailableView { + UnavailableStateView { Label("Nothing to Copy", systemImage: "tray") } description: { Text("This database reports no objects.") diff --git a/TablePro/Views/ObjectCopy/CopyObjectsProgressView.swift b/TablePro/Views/ObjectCopy/CopyObjectsProgressView.swift index f1eac467dc..cd82f66a21 100644 --- a/TablePro/Views/ObjectCopy/CopyObjectsProgressView.swift +++ b/TablePro/Views/ObjectCopy/CopyObjectsProgressView.swift @@ -13,7 +13,7 @@ import SwiftUI internal struct CopyObjectsProgressView: View { - internal let session: ObjectCopySession + @ObservedObject internal var session: ObjectCopySession internal var body: some View { VStack(spacing: 16) { diff --git a/TablePro/Views/ObjectCopy/CopyObjectsResultView.swift b/TablePro/Views/ObjectCopy/CopyObjectsResultView.swift index dfd3a721fe..b537032853 100644 --- a/TablePro/Views/ObjectCopy/CopyObjectsResultView.swift +++ b/TablePro/Views/ObjectCopy/CopyObjectsResultView.swift @@ -12,7 +12,7 @@ import SwiftUI internal struct CopyObjectsResultView: View { - internal let session: ObjectCopySession + @ObservedObject internal var session: ObjectCopySession internal var body: some View { if let result = session.result { diff --git a/TablePro/Views/ObjectCopy/CopyObjectsReviewView.swift b/TablePro/Views/ObjectCopy/CopyObjectsReviewView.swift index f2490cf08f..26a8b13dbf 100644 --- a/TablePro/Views/ObjectCopy/CopyObjectsReviewView.swift +++ b/TablePro/Views/ObjectCopy/CopyObjectsReviewView.swift @@ -12,7 +12,7 @@ import SwiftUI internal struct CopyObjectsReviewView: View { - internal let session: ObjectCopySession + @ObservedObject internal var session: ObjectCopySession internal var body: some View { if let plan = session.plan { diff --git a/TablePro/Views/ObjectCopy/CopyObjectsSheet.swift b/TablePro/Views/ObjectCopy/CopyObjectsSheet.swift index 2c5c867726..5fa4b2011d 100644 --- a/TablePro/Views/ObjectCopy/CopyObjectsSheet.swift +++ b/TablePro/Views/ObjectCopy/CopyObjectsSheet.swift @@ -15,11 +15,11 @@ import SwiftUI internal struct CopyObjectsSheet: View { @Environment(\.dismiss) private var dismiss - @State private var session: ObjectCopySession + @StateObject private var session: ObjectCopySession @State private var isChoosingTarget = false internal init(launch: ObjectCopyLaunchRequest, connection: DatabaseConnection) { - _session = State(initialValue: ObjectCopySession( + _session = StateObject(wrappedValue: ObjectCopySession( mode: launch.mode, source: launch.source, sourceConnection: connection, diff --git a/TablePro/Views/ObjectSource/ObjectSourceTabView.swift b/TablePro/Views/ObjectSource/ObjectSourceTabView.swift index 3b7d855957..22a130d07c 100644 --- a/TablePro/Views/ObjectSource/ObjectSourceTabView.swift +++ b/TablePro/Views/ObjectSource/ObjectSourceTabView.swift @@ -5,12 +5,12 @@ // Tab showing the source of one stored procedure, function, trigger, user-defined type or view. // +import Combine import SwiftUI import TableProPluginKit @MainActor -@Observable -final class ObjectSourceLoader { +final class ObjectSourceLoader: ObservableObject { enum State { case loading case loaded(source: String, attributes: [ObjectAttribute], enumLabels: [String]) @@ -24,11 +24,11 @@ final class ObjectSourceLoader { let userType: UserDefinedTypeInfo? } - private(set) var state: State = .loading + @Published private(set) var state: State = .loading /// The type as the server last described it, so an edit addresses the object on screen rather /// than whatever the sidebar listed when the tab was opened. - private(set) var userType: UserDefinedTypeInfo? + @Published private(set) var userType: UserDefinedTypeInfo? private let connectionId: UUID private let objectRef: DatabaseObjectRef @@ -125,7 +125,7 @@ struct ObjectSourceTabView: View { let objectRef: DatabaseObjectRef let onOpenInEditor: (String) -> Void - @State private var loader: ObjectSourceLoader + @StateObject private var loader: ObjectSourceLoader init( connectionId: UUID, @@ -137,7 +137,7 @@ struct ObjectSourceTabView: View { self.databaseType = databaseType self.objectRef = objectRef self.onOpenInEditor = onOpenInEditor - _loader = State(wrappedValue: ObjectSourceLoader(connectionId: connectionId, objectRef: objectRef)) + _loader = StateObject(wrappedValue: ObjectSourceLoader(connectionId: connectionId, objectRef: objectRef)) } var body: some View { @@ -201,7 +201,7 @@ struct ObjectSourceTabView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(nsColor: .textBackgroundColor)) case .failed(let message): - ContentUnavailableView { + UnavailableStateView { Label("Source Unavailable", systemImage: "exclamationmark.triangle") } description: { RevealedTextView(message) diff --git a/TablePro/Views/QueryInsights/QueryInsightsToolbar.swift b/TablePro/Views/QueryInsights/QueryInsightsToolbar.swift index 92deaeef33..1a71e0ce51 100644 --- a/TablePro/Views/QueryInsights/QueryInsightsToolbar.swift +++ b/TablePro/Views/QueryInsights/QueryInsightsToolbar.swift @@ -1,7 +1,7 @@ import SwiftUI struct QueryInsightsToolbar: View { - @Bindable var viewModel: QueryInsightsViewModel + @ObservedObject var viewModel: QueryInsightsViewModel var body: some View { VStack(spacing: 0) { diff --git a/TablePro/Views/QueryInsights/QueryInsightsView.swift b/TablePro/Views/QueryInsights/QueryInsightsView.swift index b4c9be5bbb..499544603d 100644 --- a/TablePro/Views/QueryInsights/QueryInsightsView.swift +++ b/TablePro/Views/QueryInsights/QueryInsightsView.swift @@ -1,15 +1,20 @@ import SwiftUI struct QueryInsightsView: View { - let viewModel: QueryInsightsViewModel - let coordinator: MainContentCoordinator + @ObservedObject var viewModel: QueryInsightsViewModel + @ObservedObject var coordinator: MainContentCoordinator /// `requiresPro` only disables the content and lays a scrim over it, so on its own it decides /// what the screen looks like and nothing about what the screen does. Activation is gated on /// the same answer, or an unlicensed Mac computes every aggregate, subscribes to history for /// the session, and leaves the numbers sitting in the view hierarchy for anything that reads it. + /// `isFeatureAvailable` reads `status` and `currentTier`, so the licence manager is + /// observed here rather than reached through `.shared`: a mid-session activation has to + /// repaint this screen. + @ObservedObject private var licenseManager = LicenseManager.shared + private var isUnlocked: Bool { - LicenseManager.shared.isFeatureAvailable(.queryInsights) + licenseManager.isFeatureAvailable(.queryInsights) } var body: some View { @@ -36,7 +41,7 @@ struct QueryInsightsView: View { ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) } else if viewModel.isStoreUnavailable { - ContentUnavailableView( + UnavailableStateView( String(localized: "History Unavailable"), systemImage: "exclamationmark.triangle", description: Text("The query history database could not be opened, so there is nothing to summarize.") @@ -51,7 +56,7 @@ struct QueryInsightsView: View { @ViewBuilder private var emptyState: some View { if viewModel.hasNarrowingFilter { - ContentUnavailableView { + UnavailableStateView { Label(String(localized: "No Queries Match"), systemImage: "line.3.horizontal.decrease.circle") } description: { Text("No queries ran in this range, from the sources you selected.") @@ -61,7 +66,7 @@ struct QueryInsightsView: View { } } } else { - ContentUnavailableView( + UnavailableStateView( String(localized: "No Queries Yet"), systemImage: "chart.bar.xaxis", description: Text("Run some queries and this tab will show which you run most, which run slowest, and which got slower.") @@ -140,7 +145,7 @@ struct QueryInsightsView: View { } private var slowestRankingPicker: some View { - Picker(String(localized: "Rank By"), selection: Bindable(viewModel).slowestRanking) { + Picker(String(localized: "Rank By"), selection: $viewModel.slowestRanking) { ForEach(QueryInsightsSlowestRanking.allCases) { ranking in Text(ranking.displayName).tag(ranking) } diff --git a/TablePro/Views/QueryPlan/QueryPlanComparisonView.swift b/TablePro/Views/QueryPlan/QueryPlanComparisonView.swift index e15c0c76ca..5eeb65e092 100644 --- a/TablePro/Views/QueryPlan/QueryPlanComparisonView.swift +++ b/TablePro/Views/QueryPlan/QueryPlanComparisonView.swift @@ -14,7 +14,7 @@ import SwiftUI struct QueryPlanComparisonView: View { - let model: QueryPlanComparisonModel + @ObservedObject var model: QueryPlanComparisonModel var body: some View { switch model.state { @@ -23,7 +23,7 @@ struct QueryPlanComparisonView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) case .empty(let reason): - ContentUnavailableView( + UnavailableStateView( reason.title, systemImage: reason.systemImage, description: Text(reason.message) @@ -32,7 +32,7 @@ struct QueryPlanComparisonView: View { .accessibilityIdentifier("query-plan-comparison-empty") case .unavailable(let message): - ContentUnavailableView( + UnavailableStateView( String(localized: "Comparison Unavailable"), systemImage: "exclamationmark.triangle", description: Text(message) diff --git a/TablePro/Views/QueryPlan/QueryPlanDetailPane.swift b/TablePro/Views/QueryPlan/QueryPlanDetailPane.swift index 82f55eaf67..707a3e14c6 100644 --- a/TablePro/Views/QueryPlan/QueryPlanDetailPane.swift +++ b/TablePro/Views/QueryPlan/QueryPlanDetailPane.swift @@ -14,7 +14,7 @@ struct QueryPlanDetailPane: View { if let node { content(for: node) } else { - ContentUnavailableView { + UnavailableStateView { Label(String(localized: "No Node Selected"), systemImage: "square.dashed") } description: { Text(String(localized: "Select a step in the plan to see what it does.")) diff --git a/TablePro/Views/QueryPlan/QueryPlanResultView.swift b/TablePro/Views/QueryPlan/QueryPlanResultView.swift index 3051c408dc..d116615ffd 100644 --- a/TablePro/Views/QueryPlan/QueryPlanResultView.swift +++ b/TablePro/Views/QueryPlan/QueryPlanResultView.swift @@ -71,9 +71,9 @@ struct QueryPlanResultView: View { @AppStorage(PreferenceKeys.queryPlanRawFontSize.name) private var fontSize: Double = 13 @AppStorage(PreferenceKeys.queryPlanBarMetric.name) private var storedBarMetric: String = "" - @Bindable var tabState: QueryPlanTabState - @Bindable var planState: QueryPlanViewState - @Bindable private var comparison: QueryPlanComparisonModel + @ObservedObject var tabState: QueryPlanTabState + @ObservedObject var planState: QueryPlanViewState + @ObservedObject private var comparison: QueryPlanComparisonModel @State private var showCopyConfirmation = false @State private var copyResetTask: Task? @@ -101,7 +101,7 @@ struct QueryPlanResultView: View { self.planContext = planContext self.tabState = tabState self.planState = planState - _comparison = Bindable(tabState.comparison) + _comparison = ObservedObject(wrappedValue: tabState.comparison) } /// Compare is offered only when there is something to compare: a plan the app could read, and a @@ -125,7 +125,7 @@ struct QueryPlanResultView: View { .task(id: plan?.rootNode.id) { availableMetrics = plan.map(QueryPlanMetricIndex.availableMetrics) ?? [] } - .onChange(of: availableModes) { _, modes in + .onChange(of: availableModes) { modes in guard !modes.contains(tabState.viewMode) else { return } tabState.viewMode = .diagram } diff --git a/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift b/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift index 79147bcbf7..ce6ad28f14 100644 --- a/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift +++ b/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift @@ -15,7 +15,7 @@ struct QuickSwitcherPanelView: View { let onSelect: (QuickSwitcherItem, QuickSwitcherCommitIntent) -> Void let onDismiss: () -> Void - @State private var viewModel: QuickSwitcherViewModel + @StateObject private var viewModel: QuickSwitcherViewModel init( schemaProvider: SQLSchemaProvider, @@ -33,7 +33,7 @@ struct QuickSwitcherPanelView: View { self.browseSchema = browseSchema self.onSelect = onSelect self.onDismiss = onDismiss - self._viewModel = State(wrappedValue: QuickSwitcherViewModel(connectionId: connectionId)) + self._viewModel = StateObject(wrappedValue: QuickSwitcherViewModel(connectionId: connectionId)) } var body: some View { @@ -68,7 +68,7 @@ struct QuickSwitcherPanelView: View { struct QuickSwitcherPanelContent: View { @Environment(\.colorSchemeContrast) private var colorSchemeContrast - @Bindable var viewModel: QuickSwitcherViewModel + @ObservedObject var viewModel: QuickSwitcherViewModel let onCommit: (QuickSwitcherItem, QuickSwitcherCommitIntent) -> Void @State private var keyMonitor: Any? @@ -203,7 +203,7 @@ struct QuickSwitcherPanelContent: View { .padding(.vertical, QuickSwitcherMetrics.listVerticalPadding) } .frame(height: listHeight) - .onChange(of: viewModel.selectedItemId) { _, newValue in + .onChange(of: viewModel.selectedItemId) { newValue in if let id = newValue { proxy.scrollTo(id) } @@ -311,7 +311,7 @@ struct QuickSwitcherPanelContent: View { .fill( isSelected ? Color.emphasizedSelectionLabel.opacity(0.2) - : Color(nsColor: .quaternarySystemFill) + : Color(nsColor: .quaternaryFill) ) ) .accessibilityHidden(true) @@ -327,7 +327,7 @@ struct QuickSwitcherPanelContent: View { .foregroundStyle(secondaryColor) .padding(.horizontal, 5) .padding(.vertical, 1) - .background(Capsule().fill(Color(nsColor: .quaternarySystemFill))) + .background(Capsule().fill(Color(nsColor: .quaternaryFill))) } if showsSubtitle(for: item, isSelected: isSelected) { diff --git a/TablePro/Views/QuickSwitcher/QuickSwitcherRowChrome.swift b/TablePro/Views/QuickSwitcher/QuickSwitcherRowChrome.swift index a80143eebc..6aa2616a88 100644 --- a/TablePro/Views/QuickSwitcher/QuickSwitcherRowChrome.swift +++ b/TablePro/Views/QuickSwitcher/QuickSwitcherRowChrome.swift @@ -34,7 +34,7 @@ internal struct QuickSwitcherKeyHint: View { .frame(minWidth: 20, minHeight: 17) .background( RoundedRectangle(cornerRadius: 4, style: .continuous) - .fill(Color(nsColor: .quaternarySystemFill)) + .fill(Color(nsColor: .quaternaryFill)) ) Text(label) .font(.caption) diff --git a/TablePro/Views/Results/ArrayJsonElementEditor.swift b/TablePro/Views/Results/ArrayJsonElementEditor.swift index de495ca188..2865f776ea 100644 --- a/TablePro/Views/Results/ArrayJsonElementEditor.swift +++ b/TablePro/Views/Results/ArrayJsonElementEditor.swift @@ -29,14 +29,14 @@ internal struct ArrayJsonElementEditor: View { detail } .onAppear(perform: selectFirstIfNeeded) - .onChange(of: rows.map(\.id), selectFirstIfNeeded) + .onChange(of: rows.map(\.id)) { _ in selectFirstIfNeeded() } } private var elementList: some View { List(selection: $selection) { /// `ForEach(rows.enumerated(), id:)` is the modern form and does not compile here: /// `EnumeratedSequence`'s `RandomAccessCollection` conformance is macOS 26, and the - /// deployment target is 14. The copy is a constant factor on the identity walk `ForEach` + /// deployment target is 13. The copy is a constant factor on the identity walk `ForEach` /// already does over every row. ForEach(Array(rows.enumerated()), id: \.element.id) { index, row in elementRow(row, index: index) @@ -94,7 +94,7 @@ internal struct ArrayJsonElementEditor: View { } } } else { - ContentUnavailableView { + UnavailableStateView { Label(String(localized: "No Element Selected"), systemImage: "list.bullet.rectangle") } description: { Text("Select an element to read or edit its JSON.") diff --git a/TablePro/Views/Results/CellImagePreviewView.swift b/TablePro/Views/Results/CellImagePreviewView.swift index 400bb46ae7..96f626f08a 100644 --- a/TablePro/Views/Results/CellImagePreviewView.swift +++ b/TablePro/Views/Results/CellImagePreviewView.swift @@ -46,7 +46,7 @@ internal struct CellImagePreviewView: View { .accessibilityElement() .accessibilityLabel(String(localized: "Image preview")) case .tooLarge(let byteCount): - ContentUnavailableView { + UnavailableStateView { Label(String(localized: "Too Large to Preview"), systemImage: "photo") } description: { Text(String( @@ -55,7 +55,7 @@ internal struct CellImagePreviewView: View { )) } case .failed: - ContentUnavailableView { + UnavailableStateView { Label(String(localized: "Could Not Render This Image"), systemImage: "exclamationmark.triangle") } description: { Text(String( diff --git a/TablePro/Views/Results/ColumnJumpPanelView.swift b/TablePro/Views/Results/ColumnJumpPanelView.swift index 5208d0c196..7274595ad8 100644 --- a/TablePro/Views/Results/ColumnJumpPanelView.swift +++ b/TablePro/Views/Results/ColumnJumpPanelView.swift @@ -12,7 +12,7 @@ import SwiftUI /// read as one family of chooser: a search field that keeps focus, a ranked list under it, and a /// footer that says what Return and Escape will do. struct ColumnJumpPanelView: View { - @State private var viewModel: ColumnJumpViewModel + @StateObject private var viewModel: ColumnJumpViewModel private let onCommit: (GridColumnEntry) -> Void init( @@ -21,7 +21,7 @@ struct ColumnJumpPanelView: View { cursorColumnIndex: Int? = nil, onCommit: @escaping (GridColumnEntry) -> Void ) { - _viewModel = State(wrappedValue: ColumnJumpViewModel( + _viewModel = StateObject(wrappedValue: ColumnJumpViewModel( entries: entries, initialQuery: initialQuery, cursorColumnIndex: cursorColumnIndex @@ -37,7 +37,7 @@ struct ColumnJumpPanelView: View { struct ColumnJumpPanelContent: View { @Environment(\.colorSchemeContrast) private var colorSchemeContrast - @Bindable var viewModel: ColumnJumpViewModel + @ObservedObject var viewModel: ColumnJumpViewModel let onCommit: (GridColumnEntry) -> Void @State private var keyMonitor: Any? @@ -123,7 +123,7 @@ struct ColumnJumpPanelContent: View { proxy.scrollTo(id) } } - .onChange(of: viewModel.selectedId) { _, newValue in + .onChange(of: viewModel.selectedId) { newValue in if let id = newValue { proxy.scrollTo(id) } @@ -176,7 +176,7 @@ struct ColumnJumpPanelContent: View { .foregroundStyle(secondaryColor) .padding(.horizontal, 5) .padding(.vertical, 1) - .background(Capsule().fill(Color(nsColor: .quaternarySystemFill))) + .background(Capsule().fill(Color(nsColor: .quaternaryFill))) } else if let position = entry.position { Text(positionLabel(position)) .font(.callout) @@ -223,7 +223,7 @@ struct ColumnJumpPanelContent: View { .fill( isSelected ? Color.emphasizedSelectionLabel.opacity(0.2) - : Color(nsColor: .quaternarySystemFill) + : Color(nsColor: .quaternaryFill) ) ) .accessibilityHidden(true) diff --git a/TablePro/Views/Results/ExecutionIndicatorView.swift b/TablePro/Views/Results/ExecutionIndicatorView.swift index e4116cc0df..b9c68b6054 100644 --- a/TablePro/Views/Results/ExecutionIndicatorView.swift +++ b/TablePro/Views/Results/ExecutionIndicatorView.swift @@ -69,7 +69,7 @@ struct ExecutionIndicatorView: View { durationReadout(timing) } } - .onChange(of: isExecuting) { _, nowExecuting in + .onChange(of: isExecuting) { nowExecuting in if nowExecuting { showsBreakdown = false } } .loadingRevealGate(isActive: isExecuting, isRevealed: $showsExecution) diff --git a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift index bd331548d8..ccb8225a34 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift @@ -503,7 +503,7 @@ extension TableViewCoordinator { ) -> NSMenuItem { switch option { case .sectionHeader(let title): - return NSMenuItem.sectionHeader(title: title) + return NSMenuItem.sectionHeaderCompat(title: title) case .value(let title, let sql): let item = NSMenuItem(title: title, action: #selector(dropdownMenuItemSelected(_:)), keyEquivalent: "") item.target = self diff --git a/TablePro/Views/Results/FilterableTreeView.swift b/TablePro/Views/Results/FilterableTreeView.swift index b8134066aa..d86d28bf0a 100644 --- a/TablePro/Views/Results/FilterableTreeView.swift +++ b/TablePro/Views/Results/FilterableTreeView.swift @@ -39,7 +39,7 @@ internal struct FilterableTreeView: View { } content(projection: projection, documentInfo: documentInfo) } - .onChange(of: searchText) { _, newValue in + .onChange(of: searchText) { newValue in guard newValue.trimmingCharacters(in: .whitespaces).isEmpty else { return } disclosure.endFiltering() } @@ -124,7 +124,7 @@ internal struct FilterableTreeView: View { @ViewBuilder private func noMatchesView(isTruncated: Bool) -> some View { if isTruncated { - ContentUnavailableView { + UnavailableStateView { Label(String(localized: "No Results"), systemImage: "magnifyingglass") } description: { Text( @@ -138,7 +138,7 @@ internal struct FilterableTreeView: View { ) } } else { - ContentUnavailableView.search(text: searchText) + UnavailableStateView.search(text: searchText) } } diff --git a/TablePro/Views/Results/FindBarView.swift b/TablePro/Views/Results/FindBarView.swift index 984e728d3b..9f79ddeea9 100644 --- a/TablePro/Views/Results/FindBarView.swift +++ b/TablePro/Views/Results/FindBarView.swift @@ -6,7 +6,7 @@ import SwiftUI struct FindBarView: View { - let coordinator: MainContentCoordinator + @ObservedObject var coordinator: MainContentCoordinator let findState: TabFindState /// Changes whenever the rows under the bar are replaced: a page change, a refresh, a re-run, a /// sort, or a value filter. The match list describes display positions, so it is stale the @@ -27,7 +27,7 @@ struct FindBarView: View { accessibilityIdentifier: "find-in-results-field" ) .frame(maxWidth: 320) - .onChange(of: term) { _, newValue in + .onChange(of: term) { newValue in coordinator.findCoordinator.setTerm(newValue) } @@ -68,7 +68,7 @@ struct FindBarView: View { .padding(.horizontal, 12) .padding(.vertical, 6) .onAppear { term = findState.term } - .onChange(of: rowsRevision) { _, _ in + .onChange(of: rowsRevision) { _ in coordinator.findCoordinator.runSearch() } } diff --git a/TablePro/Views/Results/ForeignKeyPickerView.swift b/TablePro/Views/Results/ForeignKeyPickerView.swift index 45fb4a4a2b..d277b58924 100644 --- a/TablePro/Views/Results/ForeignKeyPickerView.swift +++ b/TablePro/Views/Results/ForeignKeyPickerView.swift @@ -151,7 +151,7 @@ struct ForeignKeyPickerView: View { .listStyle(.plain) .scrollContentBackground(.hidden) .frame(height: 220) - .onChange(of: selection) { _, newValue in + .onChange(of: selection) { newValue in guard let newValue else { return } proxy.scrollTo(newValue) } diff --git a/TablePro/Views/Results/ForeignKeyPreviewView.swift b/TablePro/Views/Results/ForeignKeyPreviewView.swift index caba12cac7..5de052a0be 100644 --- a/TablePro/Views/Results/ForeignKeyPreviewView.swift +++ b/TablePro/Views/Results/ForeignKeyPreviewView.swift @@ -5,15 +5,15 @@ // Read-only popover showing the referenced row for a foreign key cell. // +import Combine import os import SwiftUI import TableProPluginKit @MainActor -@Observable -final class FKPreviewModel { - var cellValue: String? - var fkInfo: ForeignKeyInfo +final class FKPreviewModel: ObservableObject { + @Published var cellValue: String? + @Published var fkInfo: ForeignKeyInfo init(cellValue: String?, fkInfo: ForeignKeyInfo) { self.cellValue = cellValue @@ -27,7 +27,7 @@ private struct FKPreviewTaskKey: Equatable { } struct ForeignKeyPreviewView: View { - let model: FKPreviewModel + @ObservedObject var model: FKPreviewModel let scope: DatabaseScope let databaseType: DatabaseType let onNavigate: () -> Void diff --git a/TablePro/Views/Results/HexEditorContentView.swift b/TablePro/Views/Results/HexEditorContentView.swift index a66ae62a3b..8b3e859bb1 100644 --- a/TablePro/Views/Results/HexEditorContentView.swift +++ b/TablePro/Views/Results/HexEditorContentView.swift @@ -139,7 +139,7 @@ struct HexEditorBody: View { .padding(.vertical, 8) } } - .onChange(of: editableHex) { _, newValue in + .onChange(of: editableHex) { newValue in scheduleValidation(newValue) } } diff --git a/TablePro/Views/Results/InlineErrorBanner.swift b/TablePro/Views/Results/InlineErrorBanner.swift index fd174460de..510ea60822 100644 --- a/TablePro/Views/Results/InlineErrorBanner.swift +++ b/TablePro/Views/Results/InlineErrorBanner.swift @@ -36,7 +36,7 @@ struct InlineErrorBanner: View { } .frame(height: min(messageHeight, maxMessageHeight)) .scrollDisabled(messageFits) - .scrollBounceBehavior(.basedOnSize) + .scrollBounceBasedOnSize() if let onFixWithAI { Button(String(localized: "Fix with AI")) { onFixWithAI() } .controlSize(.small) diff --git a/TablePro/Views/Results/JSONCodeEditor.swift b/TablePro/Views/Results/JSONCodeEditor.swift index 458cf9fb6b..df1d9a0585 100644 --- a/TablePro/Views/Results/JSONCodeEditor.swift +++ b/TablePro/Views/Results/JSONCodeEditor.swift @@ -33,10 +33,10 @@ internal struct JSONCodeEditor: View { state: $editorState ) .frame(maxWidth: .infinity, maxHeight: .infinity) - .onChange(of: colorScheme) { + .onChange(of: colorScheme) { _ in rebuildConfiguration() } - .onChange(of: AppSettingsManager.shared.editor) { + .onChange(of: AppSettingsManager.shared.editor) { _ in rebuildConfiguration() } .onReceive(AppEvents.shared.accessibilityTextSizeChanged) { _ in diff --git a/TablePro/Views/Results/JSONViewerView.swift b/TablePro/Views/Results/JSONViewerView.swift index b3a5896502..193842845a 100644 --- a/TablePro/Views/Results/JSONViewerView.swift +++ b/TablePro/Views/Results/JSONViewerView.swift @@ -56,9 +56,9 @@ internal struct JSONViewerView: View { } } .onAppear { initializeView() } - .onChange(of: text) { syncFromExternal() } - .onChange(of: displayText) { handleDisplayTextChange() } - .onChange(of: viewMode) { + .onChange(of: text) { _ in syncFromExternal() } + .onChange(of: displayText) { _ in handleDisplayTextChange() } + .onChange(of: viewMode) { _ in AppSettingsManager.shared.editor.jsonViewerPreferredMode = viewMode } .alert("Invalid JSON", isPresented: $showInvalidAlert) { @@ -112,7 +112,7 @@ internal struct JSONViewerView: View { } private func treeErrorView(_ error: JSONTreeParseError) -> some View { - ContentUnavailableView { + UnavailableStateView { Label( error == .tooLarge ? String(localized: "JSON Too Large") diff --git a/TablePro/Views/Results/PhpViewerView.swift b/TablePro/Views/Results/PhpViewerView.swift index f4fa80dc1e..01760a35a3 100644 --- a/TablePro/Views/Results/PhpViewerView.swift +++ b/TablePro/Views/Results/PhpViewerView.swift @@ -136,7 +136,7 @@ internal struct PhpViewerView: View { } private func errorPlaceholder(title: String, detail: String, systemImage: String) -> some View { - ContentUnavailableView { + UnavailableStateView { Label(title, systemImage: systemImage) } description: { Text(detail) diff --git a/TablePro/Views/Results/ResultChartCanvas.swift b/TablePro/Views/Results/ResultChartCanvas.swift index 799b3e871f..e961fa4ce5 100644 --- a/TablePro/Views/Results/ResultChartCanvas.swift +++ b/TablePro/Views/Results/ResultChartCanvas.swift @@ -82,7 +82,7 @@ struct ResultChartCanvas: View { selectionMark(at: x, selection: selection) } }) - .chartXSelection(value: $selectedCategory) + .chartXSelectionCompat(value: $selectedCategory) } private var numericChart: some View { @@ -97,7 +97,7 @@ struct ResultChartCanvas: View { selectionMark(at: x, selection: selection) } }) - .chartXSelection(value: $selectedNumber) + .chartXSelectionCompat(value: $selectedNumber) } private var dateChart: some View { @@ -112,7 +112,7 @@ struct ResultChartCanvas: View { selectionMark(at: x, selection: selection) } }) - .chartXSelection(value: $selectedDate) + .chartXSelectionCompat(value: $selectedDate) } /// The per-series stroke lives on the chart's line-style scale, not on the mark: a constant @@ -307,23 +307,39 @@ struct ResultChartCanvas: View { @ChartContentBuilder private func selectionMark(at x: X, selection: ResultChartSelection) -> some ChartContent { - RuleMark(x: .value(projection.xAxisLabel, x)) - .foregroundStyle(Color(nsColor: .secondaryLabelColor).opacity(0.7)) - .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 4])) - .annotation( - position: .top, - alignment: .leading, - spacing: 8, - overflowResolution: AnnotationOverflowResolution(x: .fit(to: .chart), y: .fit(to: .chart)) - ) { - ResultChartSelectionCallout( - selection: selection, - xAxisLabel: projection.xAxisLabel, - yAxisLabel: projection.yAxisLabel, - seriesLabel: projection.seriesLabel - ) - } - .accessibilityHidden(true) + if #available(macOS 14.0, *) { + RuleMark(x: .value(projection.xAxisLabel, x)) + .foregroundStyle(Color(nsColor: .secondaryLabelColor).opacity(0.7)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 4])) + .annotation( + position: .top, + alignment: .leading, + spacing: 8, + overflowResolution: AnnotationOverflowResolution(x: .fit(to: .chart), y: .fit(to: .chart)) + ) { + callout(for: selection) + } + .accessibilityHidden(true) + } else { + /// `overflowResolution` is macOS 14. Without it the callout can run past the plot + /// edge at the extremes of the axis; the position and spacing are unchanged. + RuleMark(x: .value(projection.xAxisLabel, x)) + .foregroundStyle(Color(nsColor: .secondaryLabelColor).opacity(0.7)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 4])) + .annotation(position: .top, alignment: .leading, spacing: 8) { + callout(for: selection) + } + .accessibilityHidden(true) + } + } + + private func callout(for selection: ResultChartSelection) -> some View { + ResultChartSelectionCallout( + selection: selection, + xAxisLabel: projection.xAxisLabel, + yAxisLabel: projection.yAxisLabel, + seriesLabel: projection.seriesLabel + ) } static func orderedSeriesNames(in projection: ResultChartProjection) -> [String] { diff --git a/TablePro/Views/Results/ResultChartToolbar.swift b/TablePro/Views/Results/ResultChartToolbar.swift index 06ba0ff889..3eaa09ffd3 100644 --- a/TablePro/Views/Results/ResultChartToolbar.swift +++ b/TablePro/Views/Results/ResultChartToolbar.swift @@ -60,7 +60,7 @@ struct ResultChartToolbar: View { } .fixedSize(horizontal: true, vertical: false) } - .scrollBounceBehavior(.basedOnSize, axes: .horizontal) + .scrollBounceBasedOnSize(axes: .horizontal) .accessibilityIdentifier("result-chart-data-scope") Image(systemName: "info.circle") diff --git a/TablePro/Views/Results/ResultChartView.swift b/TablePro/Views/Results/ResultChartView.swift index 9e6188e8a8..fb0a7a5ce2 100644 --- a/TablePro/Views/Results/ResultChartView.swift +++ b/TablePro/Views/Results/ResultChartView.swift @@ -99,13 +99,13 @@ struct ResultChartView: View { @ViewBuilder private var content: some View { if tableRows.rows.isEmpty { - ContentUnavailableView( + UnavailableStateView( String(localized: "No Data"), systemImage: "chart.bar.xaxis", description: Text(String(localized: "Execute a query to chart its loaded rows.")) ) } else if resolved == nil { - ContentUnavailableView( + UnavailableStateView( String(localized: "No Numeric Column"), systemImage: "slider.horizontal.3", description: Text(String(localized: "Charts need a numeric column for the Y axis. This result has none.")) @@ -117,7 +117,7 @@ struct ResultChartView: View { .controlSize(.small) .accessibilityLabel(String(localized: "Building chart")) case .loaded(let projection) where projection.points.isEmpty: - ContentUnavailableView( + UnavailableStateView( String(localized: "No Chartable Rows"), systemImage: "chart.bar.xaxis", description: Text(String(localized: "The selected axes contain only null, binary, or invalid values.")) diff --git a/TablePro/Views/Results/ResultMapView.swift b/TablePro/Views/Results/ResultMapView.swift index a61e396d87..01b58118f6 100644 --- a/TablePro/Views/Results/ResultMapView.swift +++ b/TablePro/Views/Results/ResultMapView.swift @@ -73,7 +73,7 @@ struct ResultMapView: View { .task(id: projectionKey) { await rebuild(for: projectionKey) } - .onChange(of: projectionKey) { + .onChange(of: projectionKey) { _ in fitToken &+= 1 } } @@ -81,13 +81,13 @@ struct ResultMapView: View { @ViewBuilder private var content: some View { if tableRows.rows.isEmpty { - ContentUnavailableView( + UnavailableStateView( String(localized: "No Data"), systemImage: "map", description: Text(String(localized: "Execute a query to map its loaded rows.")) ) } else if resolved == nil { - ContentUnavailableView( + UnavailableStateView( String(localized: "No Spatial Column"), systemImage: "map", description: Text(String(localized: "A map needs a geometry or geography column. This result has none.")) @@ -99,7 +99,7 @@ struct ResultMapView: View { .controlSize(.small) .accessibilityLabel(String(localized: "Building map")) case .loaded(let projection) where projection.isEmpty: - ContentUnavailableView { + UnavailableStateView { Label(String(localized: "Nothing to Draw"), systemImage: "map") } description: { Text(emptyProjectionReason(projection)) diff --git a/TablePro/Views/Results/ResultSetMenu.swift b/TablePro/Views/Results/ResultSetMenu.swift index aad0428941..65e4d33ba1 100644 --- a/TablePro/Views/Results/ResultSetMenu.swift +++ b/TablePro/Views/Results/ResultSetMenu.swift @@ -68,7 +68,7 @@ struct ResultSetMenu: View { } } .menuStyle(.button) - .buttonStyle(.accessoryBar) + .accessoryBarStyle() .controlSize(.small) .fixedSize() .help(String(localized: "Choose which result this pane shows")) diff --git a/TablePro/Views/Results/ResultStatusBar.swift b/TablePro/Views/Results/ResultStatusBar.swift index 2935748a6e..e9501f0403 100644 --- a/TablePro/Views/Results/ResultStatusBar.swift +++ b/TablePro/Views/Results/ResultStatusBar.swift @@ -64,15 +64,15 @@ struct ResultStatusBar: View { row(.narrow) } .statusBarChrome() - .onChange(of: snapshot.tabId) { _, _ in + .onChange(of: snapshot.tabId) { _ in showColumnPopover = false showHighlightPopover = false } - .onChange(of: showHighlightPopover) { _, isShown in + .onChange(of: showHighlightPopover) { isShown in guard !isShown else { return } highlightState.onDismiss() } - .onChange(of: highlightPresentation) { previous, current in + .onValueChange(of: highlightPresentation) { previous, current in guard previous.tabId == current.tabId, model.controls.showsHighlightRules else { return } showHighlightPopover = true } @@ -165,14 +165,14 @@ struct ResultStatusBar: View { String(localized: "Count Exactly"), action: paginationCallbacks.onRequestExactCount ) - .buttonStyle(.accessoryBarAction) + .accessoryBarActionStyle() .help(String(localized: "Replace the estimate with an exact row count.")) .accessibilityIdentifier("result-status-count-exactly") } if model.controls.showsFetchAll, let onFetchAll { Button(String(localized: "Fetch All"), action: onFetchAll) - .buttonStyle(.accessoryBarAction) + .accessoryBarActionStyle() .help(String(localized: "Load the rows the row cap left behind.")) .accessibilityIdentifier("result-status-fetch-all") } diff --git a/TablePro/Views/Results/ResultsJsonView.swift b/TablePro/Views/Results/ResultsJsonView.swift index d96114e889..1e6b3a2466 100644 --- a/TablePro/Views/Results/ResultsJsonView.swift +++ b/TablePro/Views/Results/ResultsJsonView.swift @@ -106,7 +106,7 @@ internal struct ResultsJsonView: View { .task(id: renderKey) { await rebuild() } - .onChange(of: viewMode) { + .onChange(of: viewMode) { _ in AppSettingsManager.shared.editor.jsonViewerPreferredMode = viewMode } } @@ -161,7 +161,7 @@ internal struct ResultsJsonView: View { @ViewBuilder private var content: some View { if tableRows.rows.isEmpty { - ContentUnavailableView( + UnavailableStateView( String(localized: "No Data"), systemImage: "curlybraces", description: Text(String(localized: "Execute a query to view results as JSON")) @@ -187,7 +187,7 @@ internal struct ResultsJsonView: View { } private func treeErrorView(_ error: JSONTreeParseError) -> some View { - ContentUnavailableView { + UnavailableStateView { Label( error == .tooLarge ? String(localized: "JSON Too Large") diff --git a/TablePro/Views/Results/TextViewerWindowController.swift b/TablePro/Views/Results/TextViewerWindowController.swift index de6d6f44f8..e332ce0e01 100644 --- a/TablePro/Views/Results/TextViewerWindowController.swift +++ b/TablePro/Views/Results/TextViewerWindowController.swift @@ -65,7 +65,7 @@ private struct TextViewerWindowContent: View { font: ThemeEngine.shared.valueFont, textContainerInset: NSSize(width: 8, height: 10) ) - .onChange(of: text) { + .onChange(of: text) { _ in guard isEditable else { return } onCommit?(text) } diff --git a/TablePro/Views/RowInspector/AssistantPaneView.swift b/TablePro/Views/RowInspector/AssistantPaneView.swift index 1c826afa50..5322546161 100644 --- a/TablePro/Views/RowInspector/AssistantPaneView.swift +++ b/TablePro/Views/RowInspector/AssistantPaneView.swift @@ -12,7 +12,7 @@ import SwiftUI /// controls and its own command, because a chat is not one of the views of a selected row. internal struct AssistantPaneView: View { internal let connection: DatabaseConnection - @Bindable internal var state: AssistantState + @ObservedObject internal var state: AssistantState @State private var showsClearConfirmation = false diff --git a/TablePro/Views/RowInspector/FieldEditors/BlobHexEditorView.swift b/TablePro/Views/RowInspector/FieldEditors/BlobHexEditorView.swift index d2f150327d..d0371b87cc 100644 --- a/TablePro/Views/RowInspector/FieldEditors/BlobHexEditorView.swift +++ b/TablePro/Views/RowInspector/FieldEditors/BlobHexEditorView.swift @@ -66,7 +66,7 @@ internal struct BlobHexEditorView: View { .lineLimit(3...8) .autocorrectionDisabled(true) .focused($isFocused) - .onChange(of: isFocused) { + .onChange(of: isFocused) { _ in if !isFocused { commitHexEdit() } @@ -76,7 +76,7 @@ internal struct BlobHexEditorView: View { statusLine } .onAppear { loadDraft() } - .onChange(of: context.value.wrappedValue) { + .onChange(of: context.value.wrappedValue) { _ in if !isFocused { loadDraft() } diff --git a/TablePro/Views/RowInspector/FieldEditors/JsonEditorView.swift b/TablePro/Views/RowInspector/FieldEditors/JsonEditorView.swift index 65428a310f..f2b1c49c15 100644 --- a/TablePro/Views/RowInspector/FieldEditors/JsonEditorView.swift +++ b/TablePro/Views/RowInspector/FieldEditors/JsonEditorView.swift @@ -32,8 +32,8 @@ internal struct JsonEditorView: View { .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color(nsColor: .separatorColor))) .overlay(alignment: .bottomTrailing) { actionButtons } } - .onChange(of: displayText) { propagateEdit() } - .onChange(of: context.value.wrappedValue) { syncFromBinding() } + .onChange(of: displayText) { _ in propagateEdit() } + .onChange(of: context.value.wrappedValue) { _ in syncFromBinding() } } private var actionButtons: some View { diff --git a/TablePro/Views/RowInspector/FieldEditors/SchemaTextFieldView.swift b/TablePro/Views/RowInspector/FieldEditors/SchemaTextFieldView.swift index 4829b0e90d..a493558350 100644 --- a/TablePro/Views/RowInspector/FieldEditors/SchemaTextFieldView.swift +++ b/TablePro/Views/RowInspector/FieldEditors/SchemaTextFieldView.swift @@ -21,11 +21,11 @@ internal struct SchemaTextFieldView: View { .focused($isFocused) .disabled(context.isReadOnly) .onAppear { draft = context.value.wrappedValue } - .onChange(of: context.value.wrappedValue) { _, newValue in + .onChange(of: context.value.wrappedValue) { newValue in guard !isFocused else { return } draft = newValue } - .onChange(of: isFocused) { _, focused in + .onChange(of: isFocused) { focused in guard !focused else { return } commit() } diff --git a/TablePro/Views/RowInspector/InspectorFieldListView.swift b/TablePro/Views/RowInspector/InspectorFieldListView.swift index 2832c19fa1..470d182009 100644 --- a/TablePro/Views/RowInspector/InspectorFieldListView.swift +++ b/TablePro/Views/RowInspector/InspectorFieldListView.swift @@ -12,7 +12,7 @@ import SwiftUI /// 100 columns or 2000. `Form(.formStyle(.grouped))` builds every row eagerly, four subviews each, /// and costs 525ms at 2000. A wide table would have paid that on every selection change. internal struct InspectorFieldListView: View { - internal let editState: MultiRowEditState + @ObservedObject internal var editState: MultiRowEditState internal let isEditable: Bool internal let databaseType: DatabaseType internal let userDefinedTypeScope: DatabaseScope? @@ -37,7 +37,7 @@ internal struct InspectorFieldListView: View { fieldList(fields) } } - .onChange(of: editState.fields.map(\.columnName)) { + .onChange(of: editState.fields.map(\.columnName)) { _ in expandedFieldID = nil focusedField = nil } @@ -101,7 +101,7 @@ internal struct InspectorFieldListView: View { } private var emptyFilterState: some View { - ContentUnavailableView( + UnavailableStateView( String(localized: "No Matching Fields"), systemImage: "line.3.horizontal.decrease.circle", description: Text(String(localized: "No field matches the current filter")) @@ -129,15 +129,10 @@ internal struct InspectorFieldListView: View { /// at 10. `.plain` lands at half of `intercellSpacing` and can be corrected onto the edge. .listStyle(.plain) .scrollContentBackground(.hidden) - .onKeyPress(keys: [.tab]) { press in - moveFocus(within: fields, forward: !press.modifiers.contains(.shift)) - } - .onKeyPress(keys: ["n", "d"]) { press in - guard press.modifiers.contains(.control), press.modifiers.contains(.option) else { - return .ignored - } - return applyStateShortcut(press.key) - } + .modifier(InspectorFieldKeyShortcuts( + moveFocus: { forward in moveFocus(within: fields, forward: forward) }, + applyStateShortcut: applyStateShortcut + )) } @ViewBuilder @@ -203,7 +198,7 @@ internal struct InspectorFieldListView: View { /// Handled while there is another field to reach, ignored at either end so the key falls /// through and focus can leave the list for the search field, the filter and the view-mode /// control. Wrapping around instead trapped the keyboard inside the row for good. - private func moveFocus(within fields: [FieldEditState], forward: Bool) -> KeyPress.Result { + private func moveFocus(within fields: [FieldEditState], forward: Bool) -> KeyPressResultCompat { guard !fields.isEmpty else { return .ignored } guard let current = focusedField, let index = fields.firstIndex(where: { $0.id == current }) else { focusedField = forward ? fields.first?.id : fields.last?.id @@ -218,7 +213,7 @@ internal struct InspectorFieldListView: View { return .handled } - private func applyStateShortcut(_ key: KeyEquivalent) -> KeyPress.Result { + private func applyStateShortcut(_ key: Character) -> KeyPressResultCompat { guard isEditable, let focusedField, let field = editState.fields.first(where: { $0.id == focusedField }), @@ -233,3 +228,40 @@ internal struct InspectorFieldListView: View { return .handled } } + +/// `onKeyPress` is macOS 14, and `KeyPress` cannot appear outside the check, so the handlers +/// are gated as a pair. Tab still moves focus on macOS 13 through the responder chain; what is +/// lost there is the Control-Option state shortcut, which the field's own menu also offers. +private struct InspectorFieldKeyShortcuts: ViewModifier { + let moveFocus: (Bool) -> KeyPressResultCompat + let applyStateShortcut: (Character) -> KeyPressResultCompat + + @ViewBuilder + func body(content: Content) -> some View { + if #available(macOS 14.0, *) { + content + .onKeyPress(keys: [.tab]) { press in + moveFocus(!press.modifiers.contains(.shift)).resolved + } + .onKeyPress(keys: ["n", "d"]) { press in + guard press.modifiers.contains(.control), press.modifiers.contains(.option) else { + return .ignored + } + return applyStateShortcut(press.characters.first ?? " ").resolved + } + } else { + content + } + } +} + +/// Mirrors `KeyPress.Result`, which is macOS 14, so the handlers can be declared outside the check. +internal enum KeyPressResultCompat { + case handled + case ignored + + @available(macOS 14.0, *) + var resolved: KeyPress.Result { + self == .handled ? .handled : .ignored + } +} diff --git a/TablePro/Views/RowInspector/JSON/JSONRowInspectorView.swift b/TablePro/Views/RowInspector/JSON/JSONRowInspectorView.swift index 7e157deac3..aa4d05b84d 100644 --- a/TablePro/Views/RowInspector/JSON/JSONRowInspectorView.swift +++ b/TablePro/Views/RowInspector/JSON/JSONRowInspectorView.swift @@ -9,7 +9,7 @@ import SwiftUI struct JSONRowInspectorView: View { - @Bindable var viewModel: JSONRowInspectorViewModel + @ObservedObject var viewModel: JSONRowInspectorViewModel let snapshot: JSONRowSnapshot? let onOpenReferencedTable: (JSONForeignKeyRef, String) -> Void @@ -33,7 +33,7 @@ struct JSONRowInspectorView: View { // MARK: - Empty State private var emptyState: some View { - ContentUnavailableView( + UnavailableStateView( String(localized: "No Row Selected"), systemImage: "curlybraces", description: Text(String(localized: "Select a row to view it as JSON")) @@ -133,7 +133,7 @@ struct JSONRowInspectorView: View { } private var noMatches: some View { - ContentUnavailableView( + UnavailableStateView( String(localized: "No Matches"), systemImage: "magnifyingglass", description: Text(String(localized: "No key or value matches this filter")) diff --git a/TablePro/Views/RowInspector/RowInspectorView.swift b/TablePro/Views/RowInspector/RowInspectorView.swift index 9c4bb1ae45..06b8c4ac04 100644 --- a/TablePro/Views/RowInspector/RowInspectorView.swift +++ b/TablePro/Views/RowInspector/RowInspectorView.swift @@ -12,7 +12,7 @@ import SwiftUI /// grows in place and the pop-out windows take anything larger, so the fields around it never go /// away. internal struct RowInspectorView: View { - @Bindable internal var state: RowInspectorState + @ObservedObject internal var state: RowInspectorState internal let connection: DatabaseConnection @Environment(\.commandActions) private var commandActions @@ -91,7 +91,7 @@ internal struct RowInspectorView: View { } private var emptyState: some View { - ContentUnavailableView( + UnavailableStateView( String(localized: "No Row Selected"), systemImage: "sidebar.right", description: Text(String(localized: "Select a row to see its fields")) diff --git a/TablePro/Views/ServerDashboard/DashboardToolbarView.swift b/TablePro/Views/ServerDashboard/DashboardToolbarView.swift index 6182602635..2659498ecb 100644 --- a/TablePro/Views/ServerDashboard/DashboardToolbarView.swift +++ b/TablePro/Views/ServerDashboard/DashboardToolbarView.swift @@ -1,7 +1,7 @@ import SwiftUI struct DashboardToolbarView: View { - @Bindable var viewModel: ServerDashboardViewModel + @ObservedObject var viewModel: ServerDashboardViewModel var body: some View { HStack(spacing: 12) { diff --git a/TablePro/Views/ServerDashboard/ServerDashboardView.swift b/TablePro/Views/ServerDashboard/ServerDashboardView.swift index 4b66efda9d..2789fb7019 100644 --- a/TablePro/Views/ServerDashboard/ServerDashboardView.swift +++ b/TablePro/Views/ServerDashboard/ServerDashboardView.swift @@ -1,7 +1,7 @@ import SwiftUI struct ServerDashboardView: View { - @Bindable var viewModel: ServerDashboardViewModel + @ObservedObject var viewModel: ServerDashboardViewModel var body: some View { VStack(spacing: 0) { @@ -9,7 +9,7 @@ struct ServerDashboardView: View { Divider() if viewModel.supportedPanels.isEmpty { - ContentUnavailableView( + UnavailableStateView( String(localized: "Dashboard Not Available"), systemImage: "gauge.with.dots.needle.0percent", description: Text("Server monitoring is not available for this database type.") diff --git a/TablePro/Views/ServerDashboard/SessionsTableView.swift b/TablePro/Views/ServerDashboard/SessionsTableView.swift index 93300d3e49..27e711e447 100644 --- a/TablePro/Views/ServerDashboard/SessionsTableView.swift +++ b/TablePro/Views/ServerDashboard/SessionsTableView.swift @@ -1,7 +1,7 @@ import SwiftUI struct SessionsTableView: View { - @Bindable var viewModel: ServerDashboardViewModel + @ObservedObject var viewModel: ServerDashboardViewModel @State private var selection: Set = [] var body: some View { @@ -78,7 +78,7 @@ struct SessionsTableView: View { } .width(60) } - .onChange(of: viewModel.sessionSortOrder) { _, newOrder in + .onChange(of: viewModel.sessionSortOrder) { newOrder in viewModel.sessions.sort(using: newOrder) } } diff --git a/TablePro/Views/Settings/AIProviderDetailSheet.swift b/TablePro/Views/Settings/AIProviderDetailSheet.swift index a1e6321e58..73df79dbfb 100644 --- a/TablePro/Views/Settings/AIProviderDetailSheet.swift +++ b/TablePro/Views/Settings/AIProviderDetailSheet.swift @@ -25,15 +25,15 @@ struct AIProviderDetailSheet: View { @State private var testResult: TestResult? @State private var testTask: Task? - @State private var copilotService = CopilotService.shared + @ObservedObject private var copilotService = CopilotService.shared @State private var copilotErrorMessage: String? - @State private var chatGPTCodexService = ChatGPTCodexService.shared + @ObservedObject private var chatGPTCodexService = ChatGPTCodexService.shared - @State private var cursorAgentService = CursorAgentService.shared - @State private var claudeAgentService = ClaudeAgentService.shared + @ObservedObject private var cursorAgentService = CursorAgentService.shared + @ObservedObject private var claudeAgentService = ClaudeAgentService.shared - @State private var xaiService = XAIService.shared + @ObservedObject private var xaiService = XAIService.shared @State private var showRemoveConfirmation = false @@ -223,7 +223,7 @@ struct AIProviderDetailSheet: View { private var apiKeyAuthSection: some View { Section { SecureField(String(localized: "API Key"), text: $apiKey) - .onChange(of: apiKey) { + .onChange(of: apiKey) { _ in testResult = nil } HStack { @@ -264,7 +264,7 @@ struct AIProviderDetailSheet: View { private var cursorAPIKeySection: some View { Section { SecureField(String(localized: "API Key"), text: $apiKey) - .onChange(of: apiKey) { testResult = nil } + .onChange(of: apiKey) { _ in testResult = nil } HStack { Spacer() Button { @@ -395,7 +395,7 @@ struct AIProviderDetailSheet: View { private var xaiAPIKeySection: some View { Section { SecureField(String(localized: "API Key"), text: $apiKey) - .onChange(of: apiKey) { testResult = nil } + .onChange(of: apiKey) { _ in testResult = nil } HStack { Spacer() Button { @@ -665,7 +665,7 @@ struct AIProviderDetailSheet: View { } if allowsEndpointField { TextField(String(localized: "Endpoint"), text: $draft.endpoint) - .onChange(of: draft.endpoint) { + .onChange(of: draft.endpoint) { _ in scheduleFetchModels() testResult = nil } diff --git a/TablePro/Views/Settings/AISettingsView.swift b/TablePro/Views/Settings/AISettingsView.swift index b01241632e..244263195f 100644 --- a/TablePro/Views/Settings/AISettingsView.swift +++ b/TablePro/Views/Settings/AISettingsView.swift @@ -14,9 +14,9 @@ struct AISettingsView: View { @State private var editingProviderID: UUID? @State private var addingProviderType: AIProviderType? @State private var pendingDeleteID: UUID? - @State private var chatGPTCodexService = ChatGPTCodexService.shared - @State private var cursorAgentService = CursorAgentService.shared - @State private var xaiService = XAIService.shared + @ObservedObject private var chatGPTCodexService = ChatGPTCodexService.shared + @ObservedObject private var cursorAgentService = CursorAgentService.shared + @ObservedObject private var xaiService = XAIService.shared @State private var providersWithKey: Set = [] var body: some View { @@ -38,7 +38,7 @@ struct AISettingsView: View { .task { await chatGPTCodexService.refreshAuthState() } .task { await cursorAgentService.refreshStatus() } .task { await xaiService.refreshAuthState() } - .onChange(of: settings.providers.map(\.id)) { + .onChange(of: settings.providers.map(\.id)) { _ in refreshKeyAvailability() } .sheet(item: editingProviderBinding) { provider in diff --git a/TablePro/Views/Settings/Appearance/ThemeEditorColorsSection.swift b/TablePro/Views/Settings/Appearance/ThemeEditorColorsSection.swift index 5e2c70ab6f..45c53dc53d 100644 --- a/TablePro/Views/Settings/Appearance/ThemeEditorColorsSection.swift +++ b/TablePro/Views/Settings/Appearance/ThemeEditorColorsSection.swift @@ -30,7 +30,7 @@ struct HexColorPicker: View { internal struct ThemeEditorColorsSection: View { private static let logger = Logger(subsystem: "com.TablePro", category: "ThemeEditorColorsSection") - private var engine: ThemeEngine { ThemeEngine.shared } + @ObservedObject private var engine = ThemeEngine.shared private var theme: ThemeDefinition { engine.activeTheme } var body: some View { diff --git a/TablePro/Views/Settings/Appearance/ThemeEditorFontsSection.swift b/TablePro/Views/Settings/Appearance/ThemeEditorFontsSection.swift index 89c9b7fc39..eb5b0575de 100644 --- a/TablePro/Views/Settings/Appearance/ThemeEditorFontsSection.swift +++ b/TablePro/Views/Settings/Appearance/ThemeEditorFontsSection.swift @@ -4,7 +4,7 @@ import SwiftUI struct ThemeEditorFontsSection: View { var onThemeDuplicated: ((ThemeDefinition) -> Void)? - private var engine: ThemeEngine { ThemeEngine.shared } + @ObservedObject private var engine = ThemeEngine.shared @State private var editingTheme: ThemeDefinition? @@ -22,7 +22,7 @@ struct ThemeEditorFontsSection: View { } .formStyle(.grouped) .scrollContentBackground(.hidden) - .onChange(of: engine.activeTheme.id) { + .onChange(of: engine.activeTheme.id) { _ in editingTheme = nil } } diff --git a/TablePro/Views/Settings/Appearance/ThemeEditorView.swift b/TablePro/Views/Settings/Appearance/ThemeEditorView.swift index e479f37be5..b8d9eefeaa 100644 --- a/TablePro/Views/Settings/Appearance/ThemeEditorView.swift +++ b/TablePro/Views/Settings/Appearance/ThemeEditorView.swift @@ -10,7 +10,7 @@ import SwiftUI internal struct ThemeEditorView: View { @Binding var selectedThemeId: String - private var engine: ThemeEngine { ThemeEngine.shared } + @ObservedObject private var engine = ThemeEngine.shared private var theme: ThemeDefinition { engine.activeTheme } private var isEditable: Bool { theme.isEditable } diff --git a/TablePro/Views/Settings/Appearance/ThemeListView.swift b/TablePro/Views/Settings/Appearance/ThemeListView.swift index 4a943deef7..e98252b328 100644 --- a/TablePro/Views/Settings/Appearance/ThemeListView.swift +++ b/TablePro/Views/Settings/Appearance/ThemeListView.swift @@ -6,7 +6,7 @@ internal struct ThemeListView: View { @Binding var selectedThemeId: String internal var slotAppearance: ThemeAppearance = .light - private var engine: ThemeEngine { ThemeEngine.shared } + @ObservedObject private var engine = ThemeEngine.shared @State private var showDeleteConfirmation = false @State private var errorMessage: String? diff --git a/TablePro/Views/Settings/AppearanceSettingsView.swift b/TablePro/Views/Settings/AppearanceSettingsView.swift index a273c883e5..762db98ede 100644 --- a/TablePro/Views/Settings/AppearanceSettingsView.swift +++ b/TablePro/Views/Settings/AppearanceSettingsView.swift @@ -13,6 +13,7 @@ enum ThemeEditSlot: Hashable { } struct AppearanceSettingsView: View { + @ObservedObject private var themeEngine = ThemeEngine.shared @Binding var settings: AppearanceSettings @State private var chosenSlot: ThemeEditSlot? @@ -20,7 +21,7 @@ struct AppearanceSettingsView: View { /// pane opens on the theme in use, but the user can switch to edit the other /// slot without changing the app's appearance mode. private var editSlot: ThemeEditSlot { - chosenSlot ?? (ThemeEngine.shared.effectiveAppearance == .dark ? .dark : .light) + chosenSlot ?? (themeEngine.effectiveAppearance == .dark ? .dark : .light) } private var slotAppearance: ThemeAppearance { diff --git a/TablePro/Views/Settings/Components/CopyableCodeBlock.swift b/TablePro/Views/Settings/Components/CopyableCodeBlock.swift index 5e54b7deb7..a9781b4425 100644 --- a/TablePro/Views/Settings/Components/CopyableCodeBlock.swift +++ b/TablePro/Views/Settings/Components/CopyableCodeBlock.swift @@ -25,7 +25,7 @@ struct CopyableCodeBlock: View { } } label: { Image(systemName: copied ? "checkmark" : "doc.on.doc") - .contentTransition(.symbolEffect(.replace)) + .symbolReplaceTransition() } .accessibilityLabel(String(localized: "Copy")) .help(String(localized: "Copy to clipboard")) diff --git a/TablePro/Views/Settings/CustomSlashCommandsSection.swift b/TablePro/Views/Settings/CustomSlashCommandsSection.swift index 980209e621..0514c15c7f 100644 --- a/TablePro/Views/Settings/CustomSlashCommandsSection.swift +++ b/TablePro/Views/Settings/CustomSlashCommandsSection.swift @@ -6,7 +6,7 @@ import SwiftUI struct CustomSlashCommandsSection: View { - @Bindable var storage: CustomSlashCommandStorage + @ObservedObject var storage: CustomSlashCommandStorage @State private var editing: CustomSlashCommand? @State private var isCreating = false @State private var saveError: String? diff --git a/TablePro/Views/Settings/License/LicenseDevicesSection.swift b/TablePro/Views/Settings/License/LicenseDevicesSection.swift index a8a725672b..12d8773fee 100644 --- a/TablePro/Views/Settings/License/LicenseDevicesSection.swift +++ b/TablePro/Views/Settings/License/LicenseDevicesSection.swift @@ -11,7 +11,7 @@ import SwiftUI /// and the proven shape for a list inside a grouped `Form` is `MCPTokenListView`, bounded by an /// explicit height because a list will not size itself to its content here. struct LicenseDevicesSection: View { - private let licenseManager = LicenseManager.shared + @ObservedObject private var licenseManager = LicenseManager.shared @State private var releaseCandidate: LicenseActivationInfo? diff --git a/TablePro/Views/Settings/License/LicenseSettingsView.swift b/TablePro/Views/Settings/License/LicenseSettingsView.swift index ec778913c4..e4a9d5edfe 100644 --- a/TablePro/Views/Settings/License/LicenseSettingsView.swift +++ b/TablePro/Views/Settings/License/LicenseSettingsView.swift @@ -10,7 +10,7 @@ import SwiftUI /// The layout is keyed on holding a license, and entitlement decides only whether a renewal field /// appears alongside it. A lapsed license still has seats to release and billing to open. struct LicenseSettingsView: View { - private let licenseManager = LicenseManager.shared + @ObservedObject private var licenseManager = LicenseManager.shared private var notice: LicenseNotice? { LicensePresentation.notice( diff --git a/TablePro/Views/Settings/License/LicenseTeamSection.swift b/TablePro/Views/Settings/License/LicenseTeamSection.swift index fb109cb990..86c8c184a0 100644 --- a/TablePro/Views/Settings/License/LicenseTeamSection.swift +++ b/TablePro/Views/Settings/License/LicenseTeamSection.swift @@ -10,7 +10,7 @@ import SwiftUI /// Invites, removals and seat counts are written through a token-authenticated API the app cannot /// authenticate against, so this reports the roster and links out for anything that changes it. struct LicenseTeamSection: View { - private let licenseManager = LicenseManager.shared + @ObservedObject private var licenseManager = LicenseManager.shared var body: some View { Section { diff --git a/TablePro/Views/Settings/Plugins/BrowsePluginsView.swift b/TablePro/Views/Settings/Plugins/BrowsePluginsView.swift index e8b7e74e75..c811ada9f8 100644 --- a/TablePro/Views/Settings/Plugins/BrowsePluginsView.swift +++ b/TablePro/Views/Settings/Plugins/BrowsePluginsView.swift @@ -6,10 +6,10 @@ import SwiftUI struct BrowsePluginsView: View { - private let registryClient = RegistryClient.shared - private let pluginManager = PluginManager.shared - private let installTracker = PluginInstallTracker.shared - private let downloadCountService = DownloadCountService.shared + @ObservedObject private var registryClient = RegistryClient.shared + @ObservedObject private var pluginManager = PluginManager.shared + @ObservedObject private var installTracker = PluginInstallTracker.shared + @ObservedObject private var downloadCountService = DownloadCountService.shared @State private var searchText = "" @State private var selectedCategory: RegistryCategory? @@ -34,10 +34,10 @@ struct BrowsePluginsView: View { } message: { Text(errorMessage) } - .onChange(of: searchText) { + .onChange(of: searchText) { _ in clearSelectionIfNeeded() } - .onChange(of: selectedCategory) { + .onChange(of: selectedCategory) { _ in clearSelectionIfNeeded() } } @@ -55,7 +55,7 @@ struct BrowsePluginsView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) case .failed(let message): - ContentUnavailableView { + UnavailableStateView { Label("Failed to Load", systemImage: "wifi.slash") } description: { Text(message) @@ -103,7 +103,7 @@ struct BrowsePluginsView: View { } if plugins.isEmpty { - ContentUnavailableView.search(text: searchText) + UnavailableStateView.search(text: searchText) .frame(maxWidth: .infinity, maxHeight: .infinity) } else { List(plugins, selection: $selectedPluginId) { plugin in diff --git a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift index e4505fc77e..cbaf7696da 100644 --- a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift +++ b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift @@ -8,10 +8,10 @@ import TableProPluginKit import UniformTypeIdentifiers struct InstalledPluginsView: View { - private let pluginManager = PluginManager.shared - private let registryClient = RegistryClient.shared - private let installTracker = PluginInstallTracker.shared - private let navigation = PluginsSettingsNavigation.shared + @ObservedObject private var pluginManager = PluginManager.shared + @ObservedObject private var registryClient = RegistryClient.shared + @ObservedObject private var installTracker = PluginInstallTracker.shared + @ObservedObject private var navigation = PluginsSettingsNavigation.shared @State private var selectedPluginId: String? @State private var searchText = "" @@ -259,16 +259,17 @@ struct InstalledPluginsView: View { .safeAreaInset(edge: .bottom, spacing: 0) { listBottomBar } - .onChange(of: navigation.pendingRequest, initial: true) { + .onAppear { revealRequestedPlugin() } + .onChange(of: navigation.pendingRequest) { _ in revealRequestedPlugin() } - .onChange(of: selectedPluginId) { _, pluginId in + .onChange(of: selectedPluginId) { pluginId in guard let pluginId else { return } proxy.scrollTo(pluginId) } } } - .onChange(of: searchText) { + .onChange(of: searchText) { _ in if let selectedPluginId, !filteredPlugins.contains(where: { $0.id == selectedPluginId }) { self.selectedPluginId = nil } diff --git a/TablePro/Views/Settings/Plugins/PluginsSettingsNavigation.swift b/TablePro/Views/Settings/Plugins/PluginsSettingsNavigation.swift index d3211ce58e..1d9bf2a889 100644 --- a/TablePro/Views/Settings/Plugins/PluginsSettingsNavigation.swift +++ b/TablePro/Views/Settings/Plugins/PluginsSettingsNavigation.swift @@ -3,12 +3,11 @@ // TablePro // +import Combine import Foundation -import Observation @MainActor -@Observable -internal final class PluginsSettingsNavigation { +internal final class PluginsSettingsNavigation: ObservableObject { internal struct Request: Equatable { let id: UUID let pluginId: String? @@ -16,7 +15,7 @@ internal final class PluginsSettingsNavigation { internal static let shared = PluginsSettingsNavigation() - internal private(set) var pendingRequest: Request? + @Published internal private(set) var pendingRequest: Request? internal init() {} diff --git a/TablePro/Views/Settings/PluginsSettingsView.swift b/TablePro/Views/Settings/PluginsSettingsView.swift index 9e6393f154..3cd80777dd 100644 --- a/TablePro/Views/Settings/PluginsSettingsView.swift +++ b/TablePro/Views/Settings/PluginsSettingsView.swift @@ -7,7 +7,7 @@ import SwiftUI struct PluginsSettingsView: View { @State private var selectedTab: PluginsSubTab = .installed - private let navigation = PluginsSettingsNavigation.shared + @ObservedObject private var navigation = PluginsSettingsNavigation.shared var body: some View { VStack(spacing: 0) { @@ -31,7 +31,11 @@ struct PluginsSettingsView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) } - .onChange(of: navigation.pendingRequest, initial: true) { _, request in + .onAppear { + guard navigation.pendingRequest != nil else { return } + selectedTab = .installed + } + .onChange(of: navigation.pendingRequest) { request in guard request != nil else { return } selectedTab = .installed } diff --git a/TablePro/Views/Settings/Profiles/CredentialProfileEditorSheet.swift b/TablePro/Views/Settings/Profiles/CredentialProfileEditorSheet.swift index 7c6718281f..b52efdd09e 100644 --- a/TablePro/Views/Settings/Profiles/CredentialProfileEditorSheet.swift +++ b/TablePro/Views/Settings/Profiles/CredentialProfileEditorSheet.swift @@ -167,7 +167,7 @@ struct CredentialProfileEditorSheet: View { Text(type.rawValue).tag(type) } } - .onChange(of: secureFieldType) { _, _ in reloadSecureFields() } + .onChange(of: secureFieldType) { _ in reloadSecureFields() } if secureFields.isEmpty { Text("This database type signs in with the username and password above.") .font(.caption) diff --git a/TablePro/Views/Settings/Sections/MCPSection.swift b/TablePro/Views/Settings/Sections/MCPSection.swift index 09bf53af86..f5429e6300 100644 --- a/TablePro/Views/Settings/Sections/MCPSection.swift +++ b/TablePro/Views/Settings/Sections/MCPSection.swift @@ -3,8 +3,8 @@ import SwiftUI struct MCPSection: View { @Binding var settings: MCPSettings - @State private var manager = MCPServerManager.shared - @State private var settingsManager = AppSettingsManager.shared + @ObservedObject private var manager = MCPServerManager.shared + @ObservedObject private var settingsManager = AppSettingsManager.shared @State private var tokenList: [MCPAuthToken] = [] @State private var showSetupSheet = false @State private var showCreateSheet = false @@ -187,7 +187,7 @@ struct MCPSection: View { } private struct MCPStatusIndicator: View { - @State private var manager = MCPServerManager.shared + @ObservedObject private var manager = MCPServerManager.shared var body: some View { IntegrationStatusIndicator(status: status, label: statusText) diff --git a/TablePro/Views/Settings/Sections/MCPTokenRevealSheet.swift b/TablePro/Views/Settings/Sections/MCPTokenRevealSheet.swift index f30cff79c0..80de9cadf5 100644 --- a/TablePro/Views/Settings/Sections/MCPTokenRevealSheet.swift +++ b/TablePro/Views/Settings/Sections/MCPTokenRevealSheet.swift @@ -94,7 +94,7 @@ struct MCPTokenRevealSheet: View { } label: { HStack(spacing: 4) { Image(systemName: tokenCopied ? "checkmark" : "doc.on.doc") - .contentTransition(.symbolEffect(.replace)) + .symbolReplaceTransition() Text(tokenCopied ? String(localized: "Copied") : String(localized: "Copy Token")) diff --git a/TablePro/Views/Settings/Sections/PairingApprovalSheet.swift b/TablePro/Views/Settings/Sections/PairingApprovalSheet.swift index 38b6944d46..42541a8517 100644 --- a/TablePro/Views/Settings/Sections/PairingApprovalSheet.swift +++ b/TablePro/Views/Settings/Sections/PairingApprovalSheet.swift @@ -131,7 +131,7 @@ struct PairingApprovalSheet: View { Text(String(localized: "Select Connections")).tag(ConnectionAccessMode.selected) } .labelsHidden() - .onChange(of: connectionAccess) { _, newValue in + .onChange(of: connectionAccess) { newValue in if newValue == .all { selectedConnectionIds = Set(connections.map(\.id)) } else if selectedConnectionIds.isEmpty { diff --git a/TablePro/Views/Settings/Sections/SyncSection.swift b/TablePro/Views/Settings/Sections/SyncSection.swift index d40916f875..43ad7de22a 100644 --- a/TablePro/Views/Settings/Sections/SyncSection.swift +++ b/TablePro/Views/Settings/Sections/SyncSection.swift @@ -7,8 +7,8 @@ import SwiftUI import TableProSyncTransport struct SyncSection: View { - @Bindable private var settingsManager = AppSettingsManager.shared - @Bindable private var syncCoordinator = SyncCoordinator.shared + @ObservedObject private var settingsManager = AppSettingsManager.shared + @ObservedObject private var syncCoordinator = SyncCoordinator.shared private var isProAvailable: Bool { LicenseManager.shared.isFeatureAvailable(.iCloudSync) @@ -17,7 +17,7 @@ struct SyncSection: View { var body: some View { Section { Toggle("Sync this Mac with iCloud", isOn: $settingsManager.sync.enabled) - .onChange(of: settingsManager.sync.enabled) { _, newValue in + .onChange(of: settingsManager.sync.enabled) { newValue in updatePasswordSyncFlag() if newValue { syncCoordinator.enableSync() @@ -96,7 +96,7 @@ struct SyncSection: View { private var categoriesSection: some View { Section("Sync Categories") { Toggle("Connections", isOn: $settingsManager.sync.syncConnections) - .onChange(of: settingsManager.sync.syncConnections) { _, newValue in + .onChange(of: settingsManager.sync.syncConnections) { newValue in if !newValue, settingsManager.sync.syncPasswords { settingsManager.sync.syncPasswords = false onPasswordSyncChanged(false) @@ -105,7 +105,7 @@ struct SyncSection: View { if settingsManager.sync.syncConnections { Toggle("Passwords", isOn: $settingsManager.sync.syncPasswords) - .onChange(of: settingsManager.sync.syncPasswords) { _, newValue in + .onChange(of: settingsManager.sync.syncPasswords) { newValue in onPasswordSyncChanged(newValue) } .help("Syncs passwords via iCloud Keychain (end-to-end encrypted).") diff --git a/TablePro/Views/Settings/SettingsWindowController.swift b/TablePro/Views/Settings/SettingsWindowController.swift index 7c8e347974..3950abc5f7 100644 --- a/TablePro/Views/Settings/SettingsWindowController.swift +++ b/TablePro/Views/Settings/SettingsWindowController.swift @@ -69,7 +69,13 @@ internal final class SettingsPaneTabViewController: NSTabViewController { } internal func select(_ pane: SettingsPane?) { - loadViewIfNeeded() + /// `loadViewIfNeeded()` is macOS 14. Reading `view` is what it does: the getter loads + /// the view when it has not been loaded yet. + if #available(macOS 14.0, *) { + loadViewIfNeeded() + } else { + _ = view + } let wanted = pane ?? persistedPane guard let index = Self.paneOrder.firstIndex(of: wanted) else { Self.logger.error("Settings pane \(wanted.rawValue, privacy: .public) has no tab and cannot be shown") @@ -122,7 +128,7 @@ internal final class SettingsPaneTabViewController: NSTabViewController { } private struct SettingsPaneContent: View { - @Bindable private var settingsManager = AppSettingsManager.shared + @ObservedObject private var settingsManager = AppSettingsManager.shared private let pane: SettingsPane diff --git a/TablePro/Views/Settings/SyncSettingsView.swift b/TablePro/Views/Settings/SyncSettingsView.swift index bad83c6d4f..cdc7bb1734 100644 --- a/TablePro/Views/Settings/SyncSettingsView.swift +++ b/TablePro/Views/Settings/SyncSettingsView.swift @@ -12,7 +12,7 @@ import TableProSyncTransport /// which is a different identity from the email on a license, and being gated by a license is not /// on its own a reason to live beside one. struct SyncSettingsView: View { - @Bindable private var syncCoordinator = SyncCoordinator.shared + @ObservedObject private var syncCoordinator = SyncCoordinator.shared var body: some View { Form { diff --git a/TablePro/Views/Shared/SelectionAwareTint.swift b/TablePro/Views/Shared/SelectionAwareTint.swift index 13b6baa237..2224f921d7 100644 --- a/TablePro/Views/Shared/SelectionAwareTint.swift +++ b/TablePro/Views/Shared/SelectionAwareTint.swift @@ -9,11 +9,19 @@ import SwiftUI /// colour, the way `NSColor.alternateSelectedControlTextColor` does in AppKit. A tint /// left at the accent colour renders accent-on-accent and disappears. enum SelectionAwareTintResolver { + @available(macOS 14.0, *) static func color(standard: Color, prominence: BackgroundProminence) -> Color { - prominence == .increased ? .emphasizedSelectionLabel : standard + color(standard: standard, isProminent: prominence == .increased) + } + + /// The prominence-free form, so the rule stays testable where `BackgroundProminence` + /// (macOS 14) cannot be named. + static func color(standard: Color, isProminent: Bool) -> Color { + isProminent ? .emphasizedSelectionLabel : standard } } +@available(macOS 14.0, *) private struct SelectionAwareTint: ViewModifier { let standard: Color @Environment(\.backgroundProminence) private var backgroundProminence @@ -28,7 +36,14 @@ private struct SelectionAwareTint: ViewModifier { extension View { /// Tints content with `color`, switching to the selected-content colour when the view /// sits on a prominent selection background. + /// `backgroundProminence` is macOS 14. Before it, a prominent selection could not be + /// detected from SwiftUI at all, so the tint stays at its standard colour. + @ViewBuilder func selectionAwareTint(_ color: Color) -> some View { - modifier(SelectionAwareTint(standard: color)) + if #available(macOS 14.0, *) { + modifier(SelectionAwareTint(standard: color)) + } else { + foregroundStyle(color) + } } } diff --git a/TablePro/Views/Sidebar/DatabaseTreeFilterPopover.swift b/TablePro/Views/Sidebar/DatabaseTreeFilterPopover.swift index ed483e6a00..a7c92f54aa 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeFilterPopover.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeFilterPopover.swift @@ -10,8 +10,8 @@ struct DatabaseTreeFilterPopover: View { @Binding var selectedDatabases: Set - @Bindable private var treeService = DatabaseTreeMetadataService.shared - @State private var settingsManager = AppSettingsManager.shared + @ObservedObject private var treeService = DatabaseTreeMetadataService.shared + @ObservedObject private var settingsManager = AppSettingsManager.shared @State private var searchText: String = "" private static let width: CGFloat = 300 @@ -61,14 +61,14 @@ struct DatabaseTreeFilterPopover: View { @ViewBuilder private var content: some View { if selectableDatabases.isEmpty { - ContentUnavailableView( + UnavailableStateView( String(localized: "No Databases"), systemImage: "cylinder", description: Text(String(localized: "Connect to load the database list.")) ) .frame(maxWidth: .infinity, minHeight: 160) } else if matchingDatabases.isEmpty { - ContentUnavailableView.search(text: searchText) + UnavailableStateView.search(text: searchText) .frame(maxWidth: .infinity, minHeight: 160) } else { databaseList diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift index c082fdc609..34e2e5d921 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift @@ -4,6 +4,7 @@ // import AppKit +import Combine import Observation import os import SwiftUI @@ -60,6 +61,8 @@ final class DatabaseTreeOutlineCoordinator: NSObject, NSTextFieldDelegate { private var hasRenderedOnce = false private var reconcileScheduled = false private var observationGeneration = 0 + private var appearanceObservation: AnyCancellable? + private var treeObservations: [AnyCancellable] = [] internal let schemaService = SchemaService.shared private var favoriteTables: Set = [] @@ -124,18 +127,12 @@ final class DatabaseTreeOutlineCoordinator: NSObject, NSTextFieldDelegate { /// sync write-back included. `refreshVisibleRows` reconfigures every row of every open window, /// so the two values are compared before it runs. private func observeObjectListAppearance() { - withObservationTracking { - _ = AppSettingsManager.shared.general - } onChange: { [weak self] in - Task { @MainActor in - guard let self else { return } - let appearance = Self.objectListAppearance() - if appearance != self.observedAppearance { - self.observedAppearance = appearance - self.refreshVisibleRows() - } - self.observeObjectListAppearance() - } + appearanceObservation = AppSettingsManager.shared.onMainActorChange { [weak self] in + guard let self else { return } + let appearance = Self.objectListAppearance() + guard appearance != self.observedAppearance else { return } + self.observedAppearance = appearance + self.refreshVisibleRows() } } @@ -217,14 +214,19 @@ final class DatabaseTreeOutlineCoordinator: NSObject, NSTextFieldDelegate { private func beginObserving() { observationGeneration += 1 let generation = observationGeneration - withObservationTracking { [weak self] in - self?.snapshotDependencies() - } onChange: { [weak self] in - Task { @MainActor in - guard let self, generation == self.observationGeneration else { return } - self.scheduleReconcile() - } + /// `snapshotDependencies` read across four objects, and `objectWillChange` is per + /// object, so each one gets its own sink. `scheduleReconcile` already coalesces, which + /// is what absorbs the wider wake set. + let reconcile: () -> Void = { [weak self] in + guard let self, generation == self.observationGeneration else { return } + self.scheduleReconcile() } + treeObservations = [ + service.onMainActorChange(reconcile), + schemaService.onMainActorChange(reconcile), + sidebarState?.onMainActorChange(reconcile), + sidebarState?.redisKeyTreeViewModel?.onMainActorChange(reconcile), + ].compactMap { $0 } } private func scheduleReconcile() { @@ -466,7 +468,9 @@ final class DatabaseTreeOutlineCoordinator: NSObject, NSTextFieldDelegate { forceNonPreview: forceNonPreview, activateGridFocus: activateGridFocus ) - FeatureTipSignals.sidebarTableOpened() + if #available(macOS 14.0, *) { + FeatureTipSignals.sidebarTableOpened() + } publishSelection() } } @@ -672,7 +676,9 @@ final class DatabaseTreeOutlineCoordinator: NSObject, NSTextFieldDelegate { ) { switch intent { case .openPermanently(let ref): - FeatureTipSignals.tableKeptOpen() + if #available(macOS 14.0, *) { + FeatureTipSignals.tableKeptOpen() + } pendingOpenWork?.cancel() pendingOpenWork = nil open(ref, activateGridFocus: true, forceNonPreview: true) diff --git a/TablePro/Views/Sidebar/DatabaseTreeView.swift b/TablePro/Views/Sidebar/DatabaseTreeView.swift index 44bd318258..783fcb2ff1 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeView.swift @@ -49,27 +49,32 @@ struct DatabaseTreeUserTypeRef: Identifiable, Equatable { } struct DatabaseTreeView: View { - @Bindable private var treeService = DatabaseTreeMetadataService.shared + @ObservedObject private var treeService = DatabaseTreeMetadataService.shared let connectionId: UUID let databaseType: DatabaseType - let viewModel: SidebarViewModel - let windowState: WindowSidebarState + @ObservedObject var viewModel: SidebarViewModel + @ObservedObject var windowState: WindowSidebarState @Binding var pendingTruncates: Set @Binding var pendingDeletes: Set let coordinator: MainContentCoordinator? - let sidebarState: SharedSidebarState - @State private var settingsManager = AppSettingsManager.shared + /// The publisher behind `activeDatabase` and `activeSchema` is the toolbar state, not the + /// coordinator, and `@ObservedObject` cannot wrap an optional. The coordinator holds it as + /// a stable `let`, so the view observes it directly. + @ObservedObject var toolbarState: ConnectionToolbarState + @ObservedObject var sidebarState: SharedSidebarState + + @ObservedObject private var settingsManager = AppSettingsManager.shared @State private var showsDatabaseProgress = false private var activeDatabase: String? { - let name = coordinator?.toolbarState.currentDatabase ?? "" + let name = toolbarState.currentDatabase return name.isEmpty ? nil : name } private var activeSchema: String? { - coordinator?.toolbarState.currentSchema + toolbarState.currentSchema } private var isConnected: Bool { @@ -216,7 +221,7 @@ struct DatabaseTreeView: View { } private var emptyDatabasesState: some View { - ContentUnavailableView( + UnavailableStateView( String(localized: "No Databases"), systemImage: "cylinder", description: Text("This server has no databases yet.") @@ -225,7 +230,7 @@ struct DatabaseTreeView: View { } private var filteredEmptyState: some View { - ContentUnavailableView { + UnavailableStateView { Label(String(localized: "No Databases Shown"), systemImage: "line.3.horizontal.decrease.circle") } description: { Text("The database filter hides every database on this connection.") diff --git a/TablePro/Views/Sidebar/FavoriteEditDialog.swift b/TablePro/Views/Sidebar/FavoriteEditDialog.swift index 578f6cb5ba..ef42caa7d9 100644 --- a/TablePro/Views/Sidebar/FavoriteEditDialog.swift +++ b/TablePro/Views/Sidebar/FavoriteEditDialog.swift @@ -23,7 +23,7 @@ internal struct FavoriteEditDialog: View { @State private var name: String = "" @State private var query: String = "" - @State private var keywordField = SQLFavoriteKeywordField() + @StateObject private var keywordField = SQLFavoriteKeywordField() @State private var isGlobal: Bool = false @State private var selectedFolderId: UUID? @State private var isSaving = false @@ -139,7 +139,7 @@ internal struct FavoriteEditDialog: View { Section { TextField("Keyword", text: $keywordField.keyword) .focused($focusedField, equals: .keyword) - .onChange(of: keywordField.keyword) { + .onChange(of: keywordField.keyword) { _ in revalidateKeyword() } @@ -158,7 +158,7 @@ internal struct FavoriteEditDialog: View { } } .toggleStyle(.checkbox) - .onChange(of: isGlobal) { + .onChange(of: isGlobal) { _ in revalidateKeyword() } } diff --git a/TablePro/Views/Sidebar/FavoritesTabView.swift b/TablePro/Views/Sidebar/FavoritesTabView.swift index f4b400b566..afbca80aec 100644 --- a/TablePro/Views/Sidebar/FavoritesTabView.swift +++ b/TablePro/Views/Sidebar/FavoritesTabView.swift @@ -4,7 +4,7 @@ import TableProImport internal struct FavoritesTabView: View { @Environment(\.sidebarRowSize) private var systemRowSize - @State private var viewModel: FavoritesSidebarViewModel + @StateObject private var viewModel: FavoritesSidebarViewModel @State private var favoriteTables: [FavoriteTablesStorage.FavoriteEntry] = [] @State private var favoriteDatabases: Set = [] @State private var folderToDelete: SQLFavoriteFolder? @@ -16,7 +16,7 @@ internal struct FavoritesTabView: View { @State private var showRemoveLinkedFolderAlert = false let connectionId: UUID let databaseType: DatabaseType - @Bindable private var sharedSidebarState: SharedSidebarState + @ObservedObject private var sharedSidebarState: SharedSidebarState let tables: [TableInfo] private var coordinator: MainContentCoordinator? @@ -69,7 +69,7 @@ internal struct FavoritesTabView: View { self.databaseType = databaseType self.sharedSidebarState = sharedSidebarState self.tables = tables - _viewModel = State(wrappedValue: FavoritesSidebarViewModel(connectionId: connectionId)) + _viewModel = StateObject(wrappedValue: FavoritesSidebarViewModel(connectionId: connectionId)) self.coordinator = coordinator } @@ -588,7 +588,7 @@ internal struct FavoritesTabView: View { /// row of three buttons is wider than the sidebar, and the view sizes its whole content to that /// row, so the description and the buttons ran past both edges and were cut off. private var emptyState: some View { - ContentUnavailableView { + UnavailableStateView { Label(String(localized: "No Favorites"), systemImage: "star") } description: { Text("Save frequently used queries, or link a folder of .sql files to share with your team.") @@ -609,14 +609,14 @@ internal struct FavoritesTabView: View { } private func noSearchMatchState(_ term: String) -> some View { - ContentUnavailableView.search(text: term) + UnavailableStateView.search(text: term) .frame(maxWidth: .infinity, maxHeight: .infinity) } /// A filter miss is not a failed search, so it never borrows the search placeholder's "check /// the spelling" advice. The filter control stays on screen above this, which is the reset. private var noFilterMatchState: some View { - ContentUnavailableView { + UnavailableStateView { Label(String(localized: "No Matching Favorites"), systemImage: "line.3.horizontal.decrease.circle") } description: { Text("No favorites match the selected environment.") diff --git a/TablePro/Views/Sidebar/LinkedFavoriteMetadataDialog.swift b/TablePro/Views/Sidebar/LinkedFavoriteMetadataDialog.swift index fbc5fba2b3..a3336bf22c 100644 --- a/TablePro/Views/Sidebar/LinkedFavoriteMetadataDialog.swift +++ b/TablePro/Views/Sidebar/LinkedFavoriteMetadataDialog.swift @@ -12,7 +12,7 @@ internal struct LinkedFavoriteMetadataDialog: View { @Environment(\.dismiss) private var dismiss @State private var name: String = "" - @State private var keywordField = SQLFavoriteKeywordField() + @StateObject private var keywordField = SQLFavoriteKeywordField() @State private var fileDescription: String = "" @State private var isSaving = false @State private var saveError: String? @@ -83,7 +83,7 @@ internal struct LinkedFavoriteMetadataDialog: View { private var keywordSection: some View { Section { TextField(String(localized: "Keyword"), text: $keywordField.keyword) - .onChange(of: keywordField.keyword) { + .onChange(of: keywordField.keyword) { _ in revalidateKeyword() } diff --git a/TablePro/Views/Sidebar/ObjectCommentSheet.swift b/TablePro/Views/Sidebar/ObjectCommentSheet.swift index 3e1c20ecce..c094b5ae55 100644 --- a/TablePro/Views/Sidebar/ObjectCommentSheet.swift +++ b/TablePro/Views/Sidebar/ObjectCommentSheet.swift @@ -68,7 +68,7 @@ struct ObjectCommentSheet: View { .controlSize(.small) .frame(maxWidth: .infinity, maxHeight: .infinity) case .loadFailed(let message): - ContentUnavailableView { + UnavailableStateView { Label("Comment Unavailable", systemImage: "exclamationmark.triangle") } description: { Text(message) diff --git a/TablePro/Views/Sidebar/SchemaPickerControl.swift b/TablePro/Views/Sidebar/SchemaPickerControl.swift index 979003b4d3..0a503c4243 100644 --- a/TablePro/Views/Sidebar/SchemaPickerControl.swift +++ b/TablePro/Views/Sidebar/SchemaPickerControl.swift @@ -19,8 +19,8 @@ struct SchemaPickerControl: View { let databaseType: DatabaseType let coordinator: MainContentCoordinator? - @Bindable private var schemaService = SchemaService.shared - @Bindable private var databaseManager = DatabaseManager.shared + @ObservedObject private var schemaService = SchemaService.shared + @ObservedObject private var databaseManager = DatabaseManager.shared private var currentSchema: String? { databaseManager.session(for: connectionId)?.browseSchema diff --git a/TablePro/Views/Sidebar/SidebarRowForeground.swift b/TablePro/Views/Sidebar/SidebarRowForeground.swift index d38a7494fd..ff653b70a3 100644 --- a/TablePro/Views/Sidebar/SidebarRowForeground.swift +++ b/TablePro/Views/Sidebar/SidebarRowForeground.swift @@ -17,11 +17,20 @@ internal enum SidebarRowForeground { if isSystem { return .system } return .normal } + + static func fallbackStyle(for role: Role) -> AnyShapeStyle { + switch role { + case .active: AnyShapeStyle(Color.accentColor) + case .system: AnyShapeStyle(.secondary) + case .normal: AnyShapeStyle(.primary) + } + } } /// Emphasis is not a role. AppKit publishes the row's background prominence into the hosted view, /// and `.primary` and `.secondary` both answer it on their own, so only the active-object tint needs /// resolving: an accent label on an accent fill reads as unselected. +@available(macOS 14.0, *) private struct SidebarRowForegroundModifier: ViewModifier { let role: SidebarRowForeground.Role @@ -46,7 +55,15 @@ private struct SidebarRowForegroundModifier: ViewModifier { } internal extension View { + /// `backgroundProminence` is macOS 14; before it the row cannot tell it sits on a + /// prominent selection, so the active tint stays at the accent colour. + @ViewBuilder func sidebarRowForeground(isActive: Bool, isSystem: Bool) -> some View { - modifier(SidebarRowForegroundModifier(role: SidebarRowForeground.role(isActive: isActive, isSystem: isSystem))) + let role = SidebarRowForeground.role(isActive: isActive, isSystem: isSystem) + if #available(macOS 14.0, *) { + modifier(SidebarRowForegroundModifier(role: role)) + } else { + foregroundStyle(SidebarRowForeground.fallbackStyle(for: role)) + } } } diff --git a/TablePro/Views/Sidebar/SidebarTreeView.swift b/TablePro/Views/Sidebar/SidebarTreeView.swift index c29518f0b1..e51b6c70cb 100644 --- a/TablePro/Views/Sidebar/SidebarTreeView.swift +++ b/TablePro/Views/Sidebar/SidebarTreeView.swift @@ -2,17 +2,17 @@ import SwiftUI import TableProPluginKit struct SidebarTreeView: View { - @Bindable private var schemaService = SchemaService.shared + @ObservedObject private var schemaService = SchemaService.shared let connectionId: UUID - let viewModel: SidebarViewModel - let windowState: WindowSidebarState - var sidebarState: SharedSidebarState + @ObservedObject var viewModel: SidebarViewModel + @ObservedObject var windowState: WindowSidebarState + @ObservedObject var sidebarState: SharedSidebarState @Binding var pendingTruncates: Set @Binding var pendingDeletes: Set weak var coordinator: MainContentCoordinator? - @State private var settingsManager = AppSettingsManager.shared + @ObservedObject private var settingsManager = AppSettingsManager.shared @State private var searchLoadTask: Task? private var activeDatabase: String? { @@ -56,7 +56,7 @@ struct SidebarTreeView: View { treeList } } - .onChange(of: searchText) { _, newValue in + .onChange(of: searchText) { newValue in scheduleSearchLoad(searchText: newValue) } } @@ -86,7 +86,7 @@ struct SidebarTreeView: View { private var emptySchemasState: some View { let entityName = PluginManager.shared.schemaEntityNamePlural(for: viewModel.databaseType) - return ContentUnavailableView( + return UnavailableStateView( String(format: String(localized: "No %@"), entityName), systemImage: "folder", description: Text(String( @@ -98,7 +98,7 @@ struct SidebarTreeView: View { } private var noMatchState: some View { - ContentUnavailableView.search(text: searchText) + UnavailableStateView.search(text: searchText) .frame(maxWidth: .infinity, maxHeight: .infinity) } diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 9cb9b5bf12..f6685aa168 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -9,14 +9,14 @@ import SwiftUI import TableProPluginKit struct SidebarView: View { - @State private var viewModel: SidebarViewModel - @State private var settingsManager = AppSettingsManager.shared + @StateObject private var viewModel: SidebarViewModel + @ObservedObject private var settingsManager = AppSettingsManager.shared @State private var showsSchemaProgress = false - private var schemaService: SchemaService { SchemaService.shared } + @ObservedObject private var schemaService = SchemaService.shared - var sidebarState: SharedSidebarState - var windowState: WindowSidebarState + @ObservedObject var sidebarState: SharedSidebarState + @ObservedObject var windowState: WindowSidebarState @Binding var pendingTruncates: Set @Binding var pendingDeletes: Set @@ -95,7 +95,7 @@ struct SidebarView: View { ) /// Nothing observable is written here. This initializer runs on every evaluation of the /// parent's body, and the view model already seeds its own filter and watches the field. - _viewModel = State(wrappedValue: vm) + _viewModel = StateObject(wrappedValue: vm) self.connectionId = connectionId self.coordinator = coordinator } @@ -106,7 +106,9 @@ struct SidebarView: View { VStack(spacing: 0) { switch sidebarState.selectedSidebarTab { case .tables: - FeatureTipInline(tip: OpenQuicklyTip(shortcut: FeatureTipShortcut.display(for: .quickSwitcher))) + if #available(macOS 14.0, *) { + FeatureTipInline(tip: OpenQuicklyTip(shortcut: FeatureTipShortcut.display(for: .quickSwitcher))) + } tablesContent case .favorites: if let coordinator { @@ -124,13 +126,13 @@ struct SidebarView: View { sidebarFooter } - .onChange(of: settingsManager.general.showRecentTables) { _, _ in + .onChange(of: settingsManager.general.showRecentTables) { _ in sidebarState.reloadRecentTablesFromStore() } .onAppear { coordinator?.sidebarViewModel = viewModel } - .onChange(of: viewModel.showOperationDialog) { _, isPresented in + .onChange(of: viewModel.showOperationDialog) { isPresented in guard isPresented else { return } presentOperationAlert() } @@ -205,16 +207,19 @@ struct SidebarView: View { @ViewBuilder private var databaseTreeContent: some View { - DatabaseTreeView( - connectionId: connectionId, - databaseType: viewModel.databaseType, - viewModel: viewModel, - windowState: windowState, - pendingTruncates: $pendingTruncates, - pendingDeletes: $pendingDeletes, - coordinator: coordinator, - sidebarState: sidebarState - ) + if let coordinator { + DatabaseTreeView( + connectionId: connectionId, + databaseType: viewModel.databaseType, + viewModel: viewModel, + windowState: windowState, + pendingTruncates: $pendingTruncates, + pendingDeletes: $pendingDeletes, + coordinator: coordinator, + toolbarState: coordinator.toolbarState, + sidebarState: sidebarState + ) + } } @ViewBuilder @@ -308,7 +313,7 @@ struct SidebarView: View { } private var noMatchState: some View { - ContentUnavailableView.search(text: viewModel.searchText) + UnavailableStateView.search(text: viewModel.searchText) .frame(maxWidth: .infinity, maxHeight: .infinity) } diff --git a/TablePro/Views/Structure/CreateTableDraft.swift b/TablePro/Views/Structure/CreateTableDraft.swift index 5a745d1082..57ab9d6104 100644 --- a/TablePro/Views/Structure/CreateTableDraft.swift +++ b/TablePro/Views/Structure/CreateTableDraft.swift @@ -3,8 +3,8 @@ // TablePro // +import Combine import Foundation -import Observation /// A table definition in progress, held outside the view that edits it. /// @@ -13,12 +13,11 @@ import Observation /// definition in the view's `@State` meant switching to any other tab and back threw away the table /// name, the options and every column the user had defined, with no prompt and nothing in Undo. @MainActor -@Observable -internal final class CreateTableDraft { +internal final class CreateTableDraft: ObservableObject { internal let changeManager = StructureChangeManager() - internal var tableName = "" - internal var tableOptions = CreateTableOptions() + @Published internal var tableName = "" + @Published internal var tableOptions = CreateTableOptions() /// Whether the draft holds anything worth losing. A tab that has only just opened does not: the /// editor seeds one blank column so the grid has a row to show, which registers as a pending diff --git a/TablePro/Views/Structure/CreateTableView.swift b/TablePro/Views/Structure/CreateTableView.swift index dfdd202407..75af9af672 100644 --- a/TablePro/Views/Structure/CreateTableView.swift +++ b/TablePro/Views/Structure/CreateTableView.swift @@ -38,15 +38,15 @@ struct CreateTableView: View { /// so taking the cursor created the table somewhere the tab never named. let scope: DatabaseScope? var coordinator: MainContentCoordinator? - let selectionState: GridSelectionState + @ObservedObject var selectionState: GridSelectionState @Environment(\.appServices) private var services /// The definition in progress. Held outside this view because the view is destroyed the moment /// the tab is deselected, and nothing in a Create Table tab exists anywhere else yet. - @Bindable var draft: CreateTableDraft + @ObservedObject var draft: CreateTableDraft - @State private var wrappedChangeManager: AnyChangeManager + @StateObject private var wrappedChangeManager: AnyChangeManager private var structureChangeManager: StructureChangeManager { draft.changeManager } @@ -77,7 +77,7 @@ struct CreateTableView: View { self.draft = draft let manager = draft.changeManager - _wrappedChangeManager = State(wrappedValue: AnyChangeManager(manager)) + _wrappedChangeManager = StateObject(wrappedValue: AnyChangeManager(manager)) _gridDelegate = State(wrappedValue: CreateTableGridDelegate( structureChangeManager: manager, structureTab: .columns, @@ -133,9 +133,9 @@ struct CreateTableView: View { coordinator?.inspectorRowSource = nil } } - .onChange(of: selectedRows) { _, newRows in selectionState.indices = newRows } - .onChange(of: selectedTab) { updateGridDelegate() } - .onChange(of: isReadyToCreate) { updateCreateTablePendingState() } + .onChange(of: selectedRows) { newRows in selectionState.indices = newRows } + .onChange(of: selectedTab) { _ in updateGridDelegate() } + .onChange(of: isReadyToCreate) { _ in updateCreateTablePendingState() } .alert(String(localized: "Create Table Failed"), isPresented: $showError) { Button("OK") {} } message: { @@ -186,7 +186,7 @@ struct CreateTableView: View { } .padding() .background(Color(nsColor: .controlBackgroundColor)) - .onChange(of: draft.tableOptions.charset) { _, newCharset in + .onChange(of: draft.tableOptions.charset) { newCharset in if let first = CreateTableOptions.collations[newCharset]?.first { draft.tableOptions.collation = first } diff --git a/TablePro/Views/Structure/DDLTextView.swift b/TablePro/Views/Structure/DDLTextView.swift index 21f48dae60..36966c7431 100644 --- a/TablePro/Views/Structure/DDLTextView.swift +++ b/TablePro/Views/Structure/DDLTextView.swift @@ -41,13 +41,13 @@ struct DDLTextView: View { state: $editorState, foldProvider: foldProvider ) - .onChange(of: ddl) { _, newDDL in + .onChange(of: ddl) { newDDL in text = newDDL } - .onChange(of: colorScheme) { + .onChange(of: colorScheme) { _ in editorConfiguration = Self.makeConfiguration(fontSize: fontSize) } - .onChange(of: fontSize) { _, newSize in + .onChange(of: fontSize) { newSize in editorConfiguration = Self.makeConfiguration(fontSize: newSize) } } diff --git a/TablePro/Views/Structure/StructureEditingSession.swift b/TablePro/Views/Structure/StructureEditingSession.swift index c0abfe3aad..e002e3529a 100644 --- a/TablePro/Views/Structure/StructureEditingSession.swift +++ b/TablePro/Views/Structure/StructureEditingSession.swift @@ -3,8 +3,8 @@ // TablePro // +import Combine import Foundation -import Observation import TableProPluginKit /// Everything one tab's structure editor is, held outside the view that presents it. @@ -32,8 +32,7 @@ import TableProPluginKit /// way back in. Holding the baseline here is what lets the rebuild skip the fetch, and skipping the /// fetch is the only version of this that keeps the edits. @MainActor -@Observable -internal final class StructureEditingSession { +internal final class StructureEditingSession: ObservableObject { /// The scope and table this session was opened against. A tab retargeted to another table gets /// a new session rather than inheriting edits staged against the old one. internal let identity: String @@ -61,48 +60,48 @@ internal final class StructureEditingSession { DatabaseScope(connectionId: connection.id, database: databaseName, schema: schemaName) } - internal var columns: [ColumnInfo] = [] - internal var indexes: [IndexInfo] = [] - internal var foreignKeys: [ForeignKeyInfo] = [] - internal var checkConstraints: [CheckConstraintInfo] = [] - internal var triggers: [TriggerInfo] = [] - internal var ddlStatement: String = "" - internal var tabData = StructureTabDataState() + @Published internal var columns: [ColumnInfo] = [] + @Published internal var indexes: [IndexInfo] = [] + @Published internal var foreignKeys: [ForeignKeyInfo] = [] + @Published internal var checkConstraints: [CheckConstraintInfo] = [] + @Published internal var triggers: [TriggerInfo] = [] + @Published internal var ddlStatement: String = "" + @Published internal var tabData = StructureTabDataState() /// Where the user was. Held here rather than in the view because two tabs on one table are two /// editors: one being on Indexes must not move the other, and neither should lose its place to /// a trip through the Data view. - internal var selectedTab: StructureTab = .columns - internal var searchText = "" - internal var sortState = SortState() - internal var sortDescriptor: StructureSortDescriptor? - internal var columnLayouts: [StructureTab: ColumnLayoutState] = [:] - internal var serverSupport = StructureServerSupport.unrestricted + @Published internal var selectedTab: StructureTab = .columns + @Published internal var searchText = "" + @Published internal var sortState = SortState() + @Published internal var sortDescriptor: StructureSortDescriptor? + @Published internal var columnLayouts: [StructureTab: ColumnLayoutState] = [:] + @Published internal var serverSupport = StructureServerSupport.unrestricted /// What the bottom bar offers while this tab is showing its structure. /// /// Keyed by tab through the session, so two structure tabs cannot answer for each other. The /// shape this replaces was one app-wide object with a `currentOwner` guard, and the guard /// existed only to work out which structure view the buttons currently belonged to. - internal var footer = StructureFooterCapability() + @Published internal var footer = StructureFooterCapability() /// Whether the opening fetch has already run. True only after a real load, so a rebuild adopts /// what is here instead of refetching, while a genuine refresh still goes through /// `onRefreshData`, which asks before discarding. - internal var hasLoaded = false + @Published internal var hasLoaded = false /// Bumped when `applyStagedChanges` has written to the database. A mounted view watches it and /// refreshes what it is showing; an unmounted one does not need to, because the apply already /// marked the tab data stale and cleared `hasLoaded`. - internal private(set) var appliedVersion = 0 + @Published internal private(set) var appliedVersion = 0 /// Raised across a save so the `onChange` handlers watching `columns`, `indexes` and /// `foreignKeys` do not mistake the post-save reload for the user editing. - internal var isApplying = false + @Published internal var isApplying = false /// When the last apply landed, used to keep an incoming refresh notification from re-fetching /// what the save has just re-fetched. - internal var lastAppliedAt: Date? + @Published internal var lastAppliedAt: Date? internal init( identity: String, diff --git a/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift index c08f1b6997..08dd0d0ef3 100644 --- a/TablePro/Views/Structure/TableStructureView.swift +++ b/TablePro/Views/Structure/TableStructureView.swift @@ -30,9 +30,9 @@ struct TableStructureView: View { let databaseName: String let schemaName: String? - let toolbarState: ConnectionToolbarState + @ObservedObject var toolbarState: ConnectionToolbarState let coordinator: MainContentCoordinator? - let selectionState: GridSelectionState + @ObservedObject var selectionState: GridSelectionState @Environment(\.appServices) var services @@ -47,7 +47,7 @@ struct TableStructureView: View { /// Everything the user has staged, plus the baseline it is staged against. Held outside this /// view because the view is destroyed whenever the tab is deselected or switched to Data. - let session: StructureEditingSession + @ObservedObject var session: StructureEditingSession /// What kind of object the tab is open on, which decides every edit it may offer. /// @@ -182,20 +182,20 @@ struct TableStructureView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } .task(loadInitialData) - .onChange(of: selectedRows) { _, newRows in + .onChange(of: selectedRows) { newRows in selectionState.indices = newRows publishFooterCapability() } - .onChange(of: selectedTab) { _, newValue in + .onChange(of: selectedTab) { newValue in onSelectedTabChanged(newValue) publishFooterCapability() } - .onChange(of: columns) { onColumnsChanged() } - .onChange(of: indexes) { onIndexesChanged() } - .onChange(of: foreignKeys) { onForeignKeysChanged() } - .onChange(of: checkConstraints) { onCheckConstraintsChanged() } - .onChange(of: searchText) { displayVersion += 1 } - .onChange(of: displayVersion) { updateGridDelegate() } + .onChange(of: columns) { _ in onColumnsChanged() } + .onChange(of: indexes) { _ in onIndexesChanged() } + .onChange(of: foreignKeys) { _ in onForeignKeysChanged() } + .onChange(of: checkConstraints) { _ in onCheckConstraintsChanged() } + .onChange(of: searchText) { _ in displayVersion += 1 } + .onChange(of: displayVersion) { _ in updateGridDelegate() } .onAppear { coordinator?.toolbarState.hasStructureChanges = structureChangeManager.hasChanges @@ -256,14 +256,14 @@ struct TableStructureView: View { coordinator?.inspectorRowSource = nil } } - .onChange(of: structureChangeManager.hasChanges) { _, newValue in + .onChange(of: structureChangeManager.hasChanges) { newValue in coordinator?.toolbarState.hasStructureChanges = newValue updateGridDelegate() } - .onChange(of: session.appliedVersion) { _, _ in + .onChange(of: session.appliedVersion) { _ in Task { await refreshAfterApply() } } - .onChange(of: structureChangeManager.reloadVersion) { _, _ in + .onChange(of: structureChangeManager.reloadVersion) { _ in // Any mutation that does not toggle hasChanges (add row when changes // already exist, undo to a still-dirty state) only bumps reloadVersion. // Bump displayVersion so SwiftUI re-evaluates structureGrid with a fresh @@ -311,8 +311,7 @@ struct TableStructureView: View { } private var toolbar: some View { - @Bindable var session = session - return HStack { + HStack { Spacer() Picker("Structure", selection: $session.selectedTab) { @@ -467,7 +466,6 @@ struct TableStructureView: View { } private var structureGrid: some View { - @Bindable var session = session let provider = makeCurrentProvider() let canEdit = editGate.allowsAnyEdit let customOptions = provider.customDropdownOptions diff --git a/TablePro/Views/Structure/TriggerDetailView.swift b/TablePro/Views/Structure/TriggerDetailView.swift index 5a0a87e30e..9ff4741479 100644 --- a/TablePro/Views/Structure/TriggerDetailView.swift +++ b/TablePro/Views/Structure/TriggerDetailView.swift @@ -5,13 +5,13 @@ // Read-only master-detail view of a table's triggers. // +import Combine import SwiftUI -@Observable -final class TriggerInspectorState { - var searchText = "" - var sortOrder: [KeyPathComparator] = [KeyPathComparator(\.name)] - var selectedID: TriggerInfo.ID? +final class TriggerInspectorState: ObservableObject { + @Published var searchText = "" + @Published var sortOrder: [KeyPathComparator] = [KeyPathComparator(\.name)] + @Published var selectedID: TriggerInfo.ID? func displayed(_ triggers: [TriggerInfo]) -> [TriggerInfo] { let filtered = searchText.isEmpty @@ -49,7 +49,7 @@ struct TriggerDetailView: View { let isLoading: Bool let onOpenInEditor: (TriggerInfo) -> Void - @State private var state = TriggerInspectorState() + @StateObject private var state = TriggerInspectorState() @State private var editorSheet: TriggerEditorSheetItem? @State private var pendingDelete: TriggerInfo? @State private var actionError: String? @@ -98,7 +98,7 @@ struct TriggerDetailView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { state.ensureSelection(triggers) } - .onChange(of: triggers) { _, newTriggers in state.ensureSelection(newTriggers) } + .onChange(of: triggers) { newTriggers in state.ensureSelection(newTriggers) } .sheet(item: $editorSheet, content: makeEditorSheet(for:)) .confirmationDialog( String(format: String(localized: "Drop trigger “%@”?"), pendingDelete?.name ?? ""), @@ -182,7 +182,7 @@ struct TriggerDetailView: View { private struct TriggerActionBar: View { let triggers: [TriggerInfo] - let state: TriggerInspectorState + @ObservedObject var state: TriggerInspectorState let canEdit: Bool let onNew: () -> Void let onEdit: (TriggerInfo) -> Void @@ -216,7 +216,7 @@ private struct TriggerActionBar: View { private struct TriggerListPane: View { let triggers: [TriggerInfo] - @Bindable var state: TriggerInspectorState + @ObservedObject var state: TriggerInspectorState private var showEnabled: Bool { triggers.contains { $0.enabled != nil } } @@ -269,7 +269,7 @@ private struct TriggerListPane: View { private struct TriggerDetailPane: View { let triggers: [TriggerInfo] - let state: TriggerInspectorState + @ObservedObject var state: TriggerInspectorState let databaseType: DatabaseType let onOpenInEditor: (TriggerInfo) -> Void diff --git a/TablePro/Views/Structure/TriggerEditorView.swift b/TablePro/Views/Structure/TriggerEditorView.swift index a10267fa59..ac37869f37 100644 --- a/TablePro/Views/Structure/TriggerEditorView.swift +++ b/TablePro/Views/Structure/TriggerEditorView.swift @@ -72,10 +72,10 @@ struct TriggerEditorView: View { } } .frame(minWidth: 560, idealWidth: 680, minHeight: 360, idealHeight: 460) - .onChange(of: colorScheme) { + .onChange(of: colorScheme) { _ in editorConfiguration = Self.makeConfiguration(fontSize: fontSize) } - .onChange(of: fontSize) { _, newSize in + .onChange(of: fontSize) { newSize in editorConfiguration = Self.makeConfiguration(fontSize: newSize) } } diff --git a/TablePro/Views/Support/SupportPromptLink.swift b/TablePro/Views/Support/SupportPromptLink.swift index 02ca344116..6ccdd57b91 100644 --- a/TablePro/Views/Support/SupportPromptLink.swift +++ b/TablePro/Views/Support/SupportPromptLink.swift @@ -11,7 +11,7 @@ import SwiftUI /// nothing to dismiss: it is a line of text that opens a window when clicked. It disappears on /// its own the moment a license is active. struct SupportPromptLink: View { - private let licenseManager = LicenseManager.shared + @ObservedObject private var licenseManager = LicenseManager.shared @ViewBuilder var body: some View { diff --git a/TablePro/Views/Support/SupportView.swift b/TablePro/Views/Support/SupportView.swift index 00afa2320f..dfdb2288af 100644 --- a/TablePro/Views/Support/SupportView.swift +++ b/TablePro/Views/Support/SupportView.swift @@ -10,7 +10,7 @@ import SwiftUI /// counter, no progress bar, no appeal. It is only ever reached because someone went looking for /// it in the Help menu. struct SupportView: View { - private let licenseManager = LicenseManager.shared + @ObservedObject private var licenseManager = LicenseManager.shared var body: some View { VStack(spacing: 20) { diff --git a/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift b/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift index 9c62253585..48a05d173a 100644 --- a/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift +++ b/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift @@ -154,7 +154,7 @@ struct ConnectionSwitcherPopover: View { reload() settleSelection() } - .onChange(of: searchText) { _, _ in + .onChange(of: searchText) { _ in settleSelection() } } diff --git a/TablePro/Views/Toolbar/ToolbarSwitcherPresenter.swift b/TablePro/Views/Toolbar/ToolbarSwitcherPresenter.swift index cbd0a20883..31cc3c1511 100644 --- a/TablePro/Views/Toolbar/ToolbarSwitcherPresenter.swift +++ b/TablePro/Views/Toolbar/ToolbarSwitcherPresenter.swift @@ -81,6 +81,7 @@ internal final class ToolbarSwitcherPresenter { /// The SwiftUI popover this replaces closed on any outside interaction. let shown = PopoverPresenter.show( relativeTo: item, + in: window, contentSize: contentSize, behavior: .transient, content: content @@ -133,6 +134,11 @@ internal final class ToolbarSwitcherPresenter { in window: NSWindow?, _ identifier: NSToolbarItem.Identifier ) -> NSToolbarItem? { + /// Anchoring a popover on a toolbar item is macOS 14, and an item whose view AppKit + /// generates reports `view` as nil, so there is nothing to anchor on below it. Answering + /// nil sends the caller to the floating panel, which is the same route an overflowed + /// toolbar already takes. + guard #available(macOS 14.0, *) else { return nil } guard let toolbar = window?.toolbar, toolbar.isVisible else { return nil } return anchor(identifier, in: toolbar.items, visible: toolbar.visibleItems ?? []) } diff --git a/TablePro/Views/UsersRoles/PrincipalAttributesForm.swift b/TablePro/Views/UsersRoles/PrincipalAttributesForm.swift index dfca6a1714..b67a16e1db 100644 --- a/TablePro/Views/UsersRoles/PrincipalAttributesForm.swift +++ b/TablePro/Views/UsersRoles/PrincipalAttributesForm.swift @@ -2,7 +2,7 @@ import SwiftUI import TableProPluginKit struct PrincipalAttributesForm: View { - @Bindable var viewModel: UsersRolesViewModel + @ObservedObject var viewModel: UsersRolesViewModel let principal: PluginPrincipalInfo private var draft: PluginPrincipalDefinition { diff --git a/TablePro/Views/UsersRoles/PrincipalDetailPane.swift b/TablePro/Views/UsersRoles/PrincipalDetailPane.swift index a348e80539..fd3f44881f 100644 --- a/TablePro/Views/UsersRoles/PrincipalDetailPane.swift +++ b/TablePro/Views/UsersRoles/PrincipalDetailPane.swift @@ -2,7 +2,7 @@ import SwiftUI import TableProPluginKit struct PrincipalDetailPane: View { - @Bindable var viewModel: UsersRolesViewModel + @ObservedObject var viewModel: UsersRolesViewModel var body: some View { VStack(spacing: 0) { @@ -11,7 +11,7 @@ struct PrincipalDetailPane: View { Divider() content(principal) } else { - ContentUnavailableView( + UnavailableStateView( String(localized: "No Selection"), systemImage: "person.2", description: Text("Select a user or role to view its privileges.") @@ -65,7 +65,7 @@ struct PrincipalDetailPane: View { } struct PendingChangesBar: View { - @Bindable var viewModel: UsersRolesViewModel + @ObservedObject var viewModel: UsersRolesViewModel var body: some View { HStack(spacing: 12) { diff --git a/TablePro/Views/UsersRoles/PrincipalListPane.swift b/TablePro/Views/UsersRoles/PrincipalListPane.swift index f00a248567..b205fb6af4 100644 --- a/TablePro/Views/UsersRoles/PrincipalListPane.swift +++ b/TablePro/Views/UsersRoles/PrincipalListPane.swift @@ -2,7 +2,7 @@ import SwiftUI import TableProPluginKit struct PrincipalListPane: View { - @Bindable var viewModel: UsersRolesViewModel + @ObservedObject var viewModel: UsersRolesViewModel @State private var sortOrder = [KeyPathComparator(\PrincipalRow.sortName)] @@ -64,7 +64,7 @@ struct PrincipalListPane: View { action: { Task { await viewModel.load(forceReload: true) } } ) } else if rows.isEmpty, !viewModel.principalFilter.isEmpty { - ContentUnavailableView.search(text: viewModel.principalFilter) + UnavailableStateView.search(text: viewModel.principalFilter) } else if rows.isEmpty { EmptyStateView( icon: "person.2", @@ -81,7 +81,7 @@ struct PrincipalListPane: View { private var table: some View { principalTable .tableStyle(.inset) - .alternatingRowBackgrounds(.enabled) + .alternatingRowBackgroundsCompat() .accessibilityIdentifier("usersroles-principal-list") .contextMenu(forSelectionType: PluginPrincipalRef.self) { refs in rowMenu(refs) @@ -89,7 +89,7 @@ struct PrincipalListPane: View { .onDeleteCommand { Task { await viewModel.requestDrop(viewModel.selectedRefs) } } - .onChange(of: viewModel.selectedRefs) { _, refs in + .onChange(of: viewModel.selectedRefs) { refs in viewModel.selection = refs.count == 1 ? refs.first : nil } .task(id: viewModel.selection) { diff --git a/TablePro/Views/UsersRoles/PrivilegeChecklistView.swift b/TablePro/Views/UsersRoles/PrivilegeChecklistView.swift index b54ee10ba5..6bc0f158d0 100644 --- a/TablePro/Views/UsersRoles/PrivilegeChecklistView.swift +++ b/TablePro/Views/UsersRoles/PrivilegeChecklistView.swift @@ -2,7 +2,7 @@ import SwiftUI import TableProPluginKit struct PrivilegeChecklistView: View { - @Bindable var viewModel: UsersRolesViewModel + @ObservedObject var viewModel: UsersRolesViewModel @State private var expansion: [String: Bool] = [:] @@ -79,19 +79,19 @@ struct PrivilegeChecklistView: View { @ViewBuilder private var content: some View { if viewModel.selection == nil { - ContentUnavailableView( + UnavailableStateView( String(localized: "No Selection"), systemImage: "person.2", description: Text("Select a user or role to view its privileges.") ) } else if viewModel.isMixedScopeSelection { - ContentUnavailableView( + UnavailableStateView( String(localized: "Mixed Selection"), systemImage: "square.stack.3d.up.slash", description: Text("Select objects of the same kind to edit their privileges.") ) } else if viewModel.selectedScopes.isEmpty { - ContentUnavailableView( + UnavailableStateView( String(localized: "No Object Selected"), systemImage: "hand.tap", description: Text("Select an object on the left to edit its privileges.") @@ -114,9 +114,9 @@ struct PrivilegeChecklistView: View { @ViewBuilder private var emptyPrivileges: some View { if !viewModel.privilegeFilter.isEmpty { - ContentUnavailableView.search(text: viewModel.privilegeFilter) + UnavailableStateView.search(text: viewModel.privilegeFilter) } else { - ContentUnavailableView( + UnavailableStateView( String(localized: "No Privileges"), systemImage: "lock", description: Text("No privileges can be granted at this level.") @@ -124,35 +124,57 @@ struct PrivilegeChecklistView: View { } } + /// `DisclosureTableRow` is macOS 14, and `@TableRowBuilder` rejects an `if #available` + /// inside it, so the whole table branches. The columns are shared. + @ViewBuilder private var table: some View { - Table(of: PrivilegeRow.self) { - TableColumn(String(localized: "Granted")) { row in - grantedCell(row) - } - .width(60) - - TableColumn(String(localized: "Privilege")) { row in - privilegeCell(row) - } - .width(min: 140, ideal: 220) - - TableColumn(String(localized: "Effective")) { row in - effectiveCell(row) + if #available(macOS 14.0, *) { + Table(of: PrivilegeRow.self) { + privilegeColumns + } rows: { + ForEach(viewModel.privilegeSections) { section in + DisclosureTableRow( + section.headerRow, + isExpanded: expansionBinding(for: section) + ) { + ForEach(section.rows) { SwiftUI.TableRow($0) } + } + } } - .width(min: 100, ideal: 180) - } rows: { - ForEach(viewModel.privilegeSections) { section in - DisclosureTableRow( - section.headerRow, - isExpanded: expansionBinding(for: section) - ) { - ForEach(section.rows) { SwiftUI.TableRow($0) } + .tableStyle(.inset) + .alternatingRowBackgroundsCompat() + .accessibilityIdentifier("usersroles-privilege-table") + } else { + Table(of: PrivilegeRow.self) { + privilegeColumns + } rows: { + /// A section reads as its header followed by its rows; what macOS 13 gives up + /// is collapsing it, so every section is shown open. + ForEach(viewModel.privilegeSections.flatMap { [$0.headerRow] + $0.rows }) { row in + SwiftUI.TableRow(row) } } + .tableStyle(.inset) + .accessibilityIdentifier("usersroles-privilege-table") + } + } + + @TableColumnBuilder + private var privilegeColumns: some TableColumnContent { + TableColumn(String(localized: "Granted")) { row in + grantedCell(row) + } + .width(60) + + TableColumn(String(localized: "Privilege")) { row in + privilegeCell(row) + } + .width(min: 140, ideal: 220) + + TableColumn(String(localized: "Effective")) { row in + effectiveCell(row) } - .tableStyle(.inset) - .alternatingRowBackgrounds(.enabled) - .accessibilityIdentifier("usersroles-privilege-table") + .width(min: 100, ideal: 180) } // MARK: - Cells diff --git a/TablePro/Views/UsersRoles/PrivilegeEditorPane.swift b/TablePro/Views/UsersRoles/PrivilegeEditorPane.swift index 8330163949..6f3484e4ac 100644 --- a/TablePro/Views/UsersRoles/PrivilegeEditorPane.swift +++ b/TablePro/Views/UsersRoles/PrivilegeEditorPane.swift @@ -2,7 +2,7 @@ import SwiftUI import TableProPluginKit struct PrivilegeEditorPane: View { - @Bindable var viewModel: UsersRolesViewModel + @ObservedObject var viewModel: UsersRolesViewModel var body: some View { AutosavingSplitView( @@ -51,13 +51,13 @@ struct PrivilegeEditorPane: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(nsColor: .controlBackgroundColor)) - .onChange(of: viewModel.scopeMode) { _, _ in + .onChange(of: viewModel.scopeMode) { _ in viewModel.applyScopeMode() } - .onChange(of: viewModel.scopeFilter) { _, _ in + .onChange(of: viewModel.scopeFilter) { _ in viewModel.searchScopes() } - .onChange(of: viewModel.selection) { _, _ in + .onChange(of: viewModel.selection) { _ in if viewModel.scopeMode == .granted { viewModel.applyScopeMode() } diff --git a/TablePro/Views/UsersRoles/PrivilegeScopeOutlineView.swift b/TablePro/Views/UsersRoles/PrivilegeScopeOutlineView.swift index 537a3d4b70..636508186e 100644 --- a/TablePro/Views/UsersRoles/PrivilegeScopeOutlineView.swift +++ b/TablePro/Views/UsersRoles/PrivilegeScopeOutlineView.swift @@ -3,7 +3,7 @@ import SwiftUI import TableProPluginKit struct PrivilegeScopeOutlineView: NSViewRepresentable { - @Bindable var viewModel: UsersRolesViewModel + @ObservedObject var viewModel: UsersRolesViewModel let structureVersion: Int let grantVersion: Int diff --git a/TablePro/Views/UsersRoles/UsersRolesSheets.swift b/TablePro/Views/UsersRoles/UsersRolesSheets.swift index c4495e9efc..fde57cd3c5 100644 --- a/TablePro/Views/UsersRoles/UsersRolesSheets.swift +++ b/TablePro/Views/UsersRoles/UsersRolesSheets.swift @@ -46,7 +46,7 @@ struct SheetChrome: View { // MARK: - Create struct CreatePrincipalSheet: View { - @Bindable var viewModel: UsersRolesViewModel + @ObservedObject var viewModel: UsersRolesViewModel @Environment(\.dismiss) private var dismiss @State private var name = "" @@ -132,7 +132,7 @@ struct CreatePrincipalSheet: View { // MARK: - Change password struct ChangePasswordSheet: View { - @Bindable var viewModel: UsersRolesViewModel + @ObservedObject var viewModel: UsersRolesViewModel let principal: PluginPrincipalRef @Environment(\.dismiss) private var dismiss @@ -185,7 +185,7 @@ struct ChangePasswordSheet: View { // MARK: - Drop struct DropPrincipalSheet: View { - @Bindable var viewModel: UsersRolesViewModel + @ObservedObject var viewModel: UsersRolesViewModel let prompt: PrincipalDropPrompt @Environment(\.dismiss) private var dismiss @@ -252,7 +252,7 @@ struct DropPrincipalSheet: View { // MARK: - Role membership struct RoleMembershipSheet: View { - @Bindable var viewModel: UsersRolesViewModel + @ObservedObject var viewModel: UsersRolesViewModel let principal: PluginPrincipalRef @Environment(\.dismiss) private var dismiss @@ -281,7 +281,7 @@ struct RoleMembershipSheet: View { .toggleStyle(.checkbox) } .listStyle(.plain) - .alternatingRowBackgrounds(.enabled) + .alternatingRowBackgroundsCompat() } } footer: { Button(String(localized: "Cancel"), role: .cancel) { dismiss() } @@ -339,7 +339,7 @@ struct RoleMembershipSheet: View { // MARK: - Copy privileges struct CopyPrivilegesSheet: View { - @Bindable var viewModel: UsersRolesViewModel + @ObservedObject var viewModel: UsersRolesViewModel let target: PluginPrincipalRef @Environment(\.dismiss) private var dismiss @@ -374,7 +374,7 @@ struct CopyPrivilegesSheet: View { .tag(row.ref) } .listStyle(.plain) - .alternatingRowBackgrounds(.enabled) + .alternatingRowBackgroundsCompat() } } footer: { Button(String(localized: "Cancel"), role: .cancel) { dismiss() } diff --git a/TablePro/Views/UsersRoles/UsersRolesTabView.swift b/TablePro/Views/UsersRoles/UsersRolesTabView.swift index 8d192db6d4..b65edfc0b8 100644 --- a/TablePro/Views/UsersRoles/UsersRolesTabView.swift +++ b/TablePro/Views/UsersRoles/UsersRolesTabView.swift @@ -3,7 +3,7 @@ import SwiftUI import TableProPluginKit struct UsersRolesTabView: View { - @Bindable var viewModel: UsersRolesViewModel + @ObservedObject var viewModel: UsersRolesViewModel let coordinator: MainContentCoordinator? let tabID: UUID @@ -47,7 +47,7 @@ struct UsersRolesTabView: View { } .onAppear { install() } .onDisappear { teardown() } - .onChange(of: viewModel.changeCount) { _, _ in + .onChange(of: viewModel.changeCount) { _ in publishChangeState() } } diff --git a/TablePro/Views/Welcome/WelcomeLibraryPane.swift b/TablePro/Views/Welcome/WelcomeLibraryPane.swift index 650245cb21..2697940173 100644 --- a/TablePro/Views/Welcome/WelcomeLibraryPane.swift +++ b/TablePro/Views/Welcome/WelcomeLibraryPane.swift @@ -8,7 +8,7 @@ import SwiftUI import TableProConnectionLibrary internal struct WelcomeLibraryPane: View { - @Bindable var viewModel: WelcomeViewModel + @ObservedObject var viewModel: WelcomeViewModel var body: some View { content @@ -57,7 +57,7 @@ internal struct WelcomeLibraryPane: View { secondaryAction: viewModel.hasImportableApp ? { viewModel.importConnectionsFromApp() } : nil ) case .noSearchMatch(let term): - ContentUnavailableView.search(text: term) + UnavailableStateView.search(text: term) case .noFilterMatch: EmptyStateView( icon: "tag", @@ -105,7 +105,7 @@ internal struct WelcomeLibraryToolbar: ToolbarContent { } internal struct WelcomeViewOptionsMenu: View { - @Bindable var viewModel: WelcomeViewModel + @ObservedObject var viewModel: WelcomeViewModel var body: some View { Menu { diff --git a/TablePro/Views/Welcome/WelcomePresentations.swift b/TablePro/Views/Welcome/WelcomePresentations.swift index ced35d5973..3e7c866afb 100644 --- a/TablePro/Views/Welcome/WelcomePresentations.swift +++ b/TablePro/Views/Welcome/WelcomePresentations.swift @@ -8,7 +8,7 @@ import TableProImport import UniformTypeIdentifiers internal struct WelcomePresentations: ViewModifier { - @Bindable var vm: WelcomeViewModel + @ObservedObject var vm: WelcomeViewModel let onSheetDismiss: () -> Void func body(content: Content) -> some View { @@ -123,7 +123,7 @@ internal struct WelcomePresentations: ViewModifier { } private struct WelcomeDeletionAlerts: ViewModifier { - @Bindable var vm: WelcomeViewModel + @ObservedObject var vm: WelcomeViewModel func body(content: Content) -> some View { content @@ -161,7 +161,7 @@ private struct WelcomeDeletionAlerts: ViewModifier { } private struct WelcomeLibraryAlerts: ViewModifier { - @Bindable var vm: WelcomeViewModel + @ObservedObject var vm: WelcomeViewModel func body(content: Content) -> some View { content @@ -197,7 +197,7 @@ private struct WelcomeLibraryAlerts: ViewModifier { } private struct WelcomeImportResultAlert: ViewModifier { - @Bindable var vm: WelcomeViewModel + @ObservedObject var vm: WelcomeViewModel func body(content: Content) -> some View { content @@ -224,7 +224,7 @@ private struct WelcomeImportResultAlert: ViewModifier { } private struct WelcomeConnectionCreationOverlays: ViewModifier { - @Bindable var vm: WelcomeViewModel + @ObservedObject var vm: WelcomeViewModel func body(content: Content) -> some View { content diff --git a/TablePro/Views/Welcome/WelcomeSplitViewController.swift b/TablePro/Views/Welcome/WelcomeSplitViewController.swift index a27d81d560..474b8c6f8a 100644 --- a/TablePro/Views/Welcome/WelcomeSplitViewController.swift +++ b/TablePro/Views/Welcome/WelcomeSplitViewController.swift @@ -40,7 +40,12 @@ internal final class WelcomeSplitViewController: NSSplitViewController { .environment(\.appServices, .live) ) list.sizingOptions = [] - list.sceneBridgingOptions = [.toolbars] + /// `sceneBridgingOptions` is macOS 14. It lets the hosted SwiftUI tree contribute toolbar + /// items to the window; on 13 the pane simply contributes none, and the window keeps the + /// toolbar the controller builds itself. + if #available(macOS 14.0, *) { + list.sceneBridgingOptions = [.toolbars] + } let listItem = NSSplitViewItem(viewController: list) listItem.minimumThickness = Self.listMinimumWidth addSplitViewItem(listItem) diff --git a/TableProTests/Core/AI/ChatTurnObservationTests.swift b/TableProTests/Core/AI/ChatTurnObservationTests.swift index 48648cdd2d..084bac748e 100644 --- a/TableProTests/Core/AI/ChatTurnObservationTests.swift +++ b/TableProTests/Core/AI/ChatTurnObservationTests.swift @@ -3,12 +3,15 @@ // TableProTests // +import Combine import Foundation -import Observation import os @testable import TablePro import Testing +/// The granularity these assert is now `objectWillChange` per object rather than +/// `@Observable`'s per property: a mutation inside a block must not wake the turn or the +/// view model, or the whole chat re-renders on every streamed token. @Suite("ChatTurn observation granularity") @MainActor struct ChatTurnObservationTests { @@ -24,11 +27,10 @@ struct ChatTurnObservationTests { viewModel.messages.append(turn) let messagesInvalidated = OSAllocatedUnfairLock(initialState: false) - withObservationTracking { - _ = viewModel.messages.count - } onChange: { + let observation = viewModel.objectWillChange.sink { _ in messagesInvalidated.withLock { $0 = true } } + defer { observation.cancel() } turn.appendStreamingToken("hello") @@ -41,11 +43,10 @@ struct ChatTurnObservationTests { let (turn, _) = makeStreamingTurn() let blockListInvalidated = OSAllocatedUnfairLock(initialState: false) - withObservationTracking { - _ = turn.blocks.count - } onChange: { + let observation = turn.objectWillChange.sink { _ in blockListInvalidated.withLock { $0 = true } } + defer { observation.cancel() } turn.appendStreamingToken("hello") @@ -57,11 +58,10 @@ struct ChatTurnObservationTests { let (turn, block) = makeStreamingTurn() let blockInvalidated = OSAllocatedUnfairLock(initialState: false) - withObservationTracking { - _ = block.kind - } onChange: { + let observation = block.objectWillChange.sink { _ in blockInvalidated.withLock { $0 = true } } + defer { observation.cancel() } turn.appendStreamingToken("hello") @@ -76,17 +76,10 @@ struct ChatTurnObservationTests { viewModel.streamingState = .streaming(assistantID: turn.id) let panelInvalidated = OSAllocatedUnfairLock(initialState: false) - withObservationTracking { - for message in viewModel.messages { - _ = message.id - _ = message.role - if !viewModel.isStreaming { - _ = message.plainText - } - } - } onChange: { + let observation = viewModel.objectWillChange.sink { _ in panelInvalidated.withLock { $0 = true } } + defer { observation.cancel() } turn.appendStreamingToken("hello") @@ -100,18 +93,16 @@ struct ChatTurnObservationTests { viewModel.messages.append(turn) let messagesInvalidated = OSAllocatedUnfairLock(initialState: false) - withObservationTracking { - _ = viewModel.messages.count - } onChange: { + let messagesObservation = viewModel.objectWillChange.sink { _ in messagesInvalidated.withLock { $0 = true } } + defer { messagesObservation.cancel() } let blockListInvalidated = OSAllocatedUnfairLock(initialState: false) - withObservationTracking { - _ = turn.blocks.count - } onChange: { + let blockListObservation = turn.objectWillChange.sink { _ in blockListInvalidated.withLock { $0 = true } } + defer { blockListObservation.cancel() } turn.appendBlock(.toolUse(ToolUseBlock(id: "t1", name: "noop", input: .object([:])))) @@ -125,11 +116,10 @@ struct ChatTurnObservationTests { let (second, _) = makeStreamingTurn() let siblingInvalidated = OSAllocatedUnfairLock(initialState: false) - withObservationTracking { - _ = first.usage - } onChange: { + let observation = first.objectWillChange.sink { _ in siblingInvalidated.withLock { $0 = true } } + defer { observation.cancel() } second.usage = AITokenUsage(inputTokens: 10, outputTokens: 20) diff --git a/TableProTests/Core/Services/Infrastructure/ConnectionWindowChromeTests.swift b/TableProTests/Core/Services/Infrastructure/ConnectionWindowChromeTests.swift index 32d26eecb5..72615a3793 100644 --- a/TableProTests/Core/Services/Infrastructure/ConnectionWindowChromeTests.swift +++ b/TableProTests/Core/Services/Infrastructure/ConnectionWindowChromeTests.swift @@ -144,6 +144,7 @@ struct ConnectionWindowChromeTests { /// Opening a row inspector needs rows. Closing one the user already opened does not, and the /// window no longer closes it for them, so leaving the command disabled would strand an empty /// column with no way to dismiss it. + @available(macOS 14.0, *) @Test("A trailing pane the user left open can still be closed with the session gone") func openTrailingPaneStaysClosable() throws { let harness = try Harness() @@ -162,6 +163,7 @@ struct ConnectionWindowChromeTests { } /// The other half of the same rule: a pane the user never opened offers nothing to open. + @available(macOS 14.0, *) @Test("A closed trailing pane stays unavailable without a session") func closedTrailingPaneStaysUnavailable() throws { let harness = try Harness() diff --git a/TableProTests/Core/Tips/FeatureTipsTests.swift b/TableProTests/Core/Tips/FeatureTipsTests.swift index 060c06c365..2e3062bd67 100644 --- a/TableProTests/Core/Tips/FeatureTipsTests.swift +++ b/TableProTests/Core/Tips/FeatureTipsTests.swift @@ -22,6 +22,7 @@ struct FeatureTipsPlanTests { ) == nil) } + @available(macOS 14.0, *) @Test("A shipped launch shows every tip and keeps the store under the support directory") func production() throws { let plan = try #require(FeatureTipsPlan.resolve( @@ -36,6 +37,7 @@ struct FeatureTipsPlanTests { #expect(plan.allows(OpenQuicklyTip.tipId)) } + @available(macOS 14.0, *) @Test("A UI test sandbox hides every tip unless the test names one") func sandboxHidesTips() throws { let plan = try #require(FeatureTipsPlan.resolve( @@ -49,6 +51,7 @@ struct FeatureTipsPlanTests { #expect(!plan.allows(OpenQuicklyTip.tipId)) } + @available(macOS 14.0, *) @Test("A UI test sandbox shows only the tips the test names") func sandboxShowsNamedTips() throws { let plan = try #require(FeatureTipsPlan.resolve( @@ -66,12 +69,14 @@ struct FeatureTipsPlanTests { @Suite("FeatureTipCatalog") struct FeatureTipCatalogTests { + @available(macOS 14.0, *) @Test("Tip ids are stored keys, so they never change") func pinnedIds() { #expect(FeatureTipCatalog.ids == ["keep-table-open", "open-quickly", "find-past-queries"]) #expect(Set(FeatureTipCatalog.ids).count == FeatureTipCatalog.ids.count) } + @available(macOS 14.0, *) @Test("Named ids map to their tip types") func typesForIds() { let types = FeatureTipCatalog.types(for: [OpenQuicklyTip.tipId]) diff --git a/TableProTests/Services/MainWindowToolbarLayoutTests.swift b/TableProTests/Services/MainWindowToolbarLayoutTests.swift index 05232a5463..0ecaa76413 100644 --- a/TableProTests/Services/MainWindowToolbarLayoutTests.swift +++ b/TableProTests/Services/MainWindowToolbarLayoutTests.swift @@ -49,6 +49,7 @@ struct MainWindowToolbarInspectorPlacementTests { /// One flexible space, immediately after the separator, is what pushes the whole trailing group /// to the window edge. Everything after it is a pane toggle; a second flexible space in there /// would split the group and let the items drift apart as the pane opens. + @available(macOS 14.0, *) @Test("A flexible space anchors the trailing toggles to the window edge") func flexibleSpaceSeparatesTheTrackingSeparatorFromTheToggle() throws { let identifiers = MainWindowToolbar.defaultItemIdentifiers @@ -75,6 +76,7 @@ struct MainWindowToolbarInspectorPlacementTests { /// Ahead of the separator the toggle lands in the content section, which measured wrong in both /// the open and the closed state. + @available(macOS 14.0, *) @Test("The inspector toggle stays behind its tracking separator") func toggleNeverPrecedesItsTrackingSeparator() throws { let identifiers = MainWindowToolbar.defaultItemIdentifiers @@ -82,6 +84,8 @@ struct MainWindowToolbarInspectorPlacementTests { #expect(!identifiers[.. = [ diff --git a/TableProTests/Views/HistoryRowTintTests.swift b/TableProTests/Views/HistoryRowTintTests.swift index 69bd85a2d8..ecac77a54c 100644 --- a/TableProTests/Views/HistoryRowTintTests.swift +++ b/TableProTests/Views/HistoryRowTintTests.swift @@ -27,6 +27,7 @@ struct HistoryRowTintTests { /// Renders the row over the same fill AppKit paints for an emphasized selection, and counts the /// pixels that are still the raw tint rather than the selected-content colour. + @available(macOS 14.0, *) private func offTintPixels( wasSuccessful: Bool, connectionLabel: HistoryConnectionLabel?, @@ -65,6 +66,7 @@ struct HistoryRowTintTests { /// The regression. A failed entry drew a fixed red glyph, which SwiftUI does not remap, so it /// sat on the accent fill at roughly 1.5:1 and read as a dark blob. + @available(macOS 14.0, *) @Test("The failure glyph leaves the accent fill when the row is emphasized") func failureGlyphAdaptsToProminence() { let standard = offTintPixels( @@ -78,6 +80,8 @@ struct HistoryRowTintTests { #expect(increased == 0) } + @available(macOS 14.0, *) + @Test("A successful entry never draws red at either prominence") func successGlyphIsNeverRed() { #expect(offTintPixels(wasSuccessful: true, connectionLabel: nil, prominence: .standard, matches: isRed) == 0) @@ -86,6 +90,7 @@ struct HistoryRowTintTests { /// A connection colour is a stored value, so it stayed itself on the fill. Green is the clearest /// of the palette to count against an accent-blue background. + @available(macOS 14.0, *) @Test("The connection dot leaves the accent fill when the row is emphasized") func connectionDotAdaptsToProminence() { let label = HistoryConnectionLabel(name: "Chinook", color: .green) diff --git a/TableProTests/Views/Settings/SettingsWindowTitleTests.swift b/TableProTests/Views/Settings/SettingsWindowTitleTests.swift index b213169c10..0f2816dda9 100644 --- a/TableProTests/Views/Settings/SettingsWindowTitleTests.swift +++ b/TableProTests/Views/Settings/SettingsWindowTitleTests.swift @@ -41,7 +41,7 @@ final class SettingsWindowTitleTests: XCTestCase { func testEveryPaneIsReachableAndCarriesItsOwnTitle() { let (panes, _) = makePanes() - panes.loadViewIfNeeded() + _ = panes.view XCTAssertEqual(SettingsPaneTabViewController.paneOrder, SettingsPane.allCases) XCTAssertEqual(panes.tabViewItems.count, SettingsPane.allCases.count) diff --git a/TableProTests/Views/Shared/SelectionAwareTintTests.swift b/TableProTests/Views/Shared/SelectionAwareTintTests.swift index 82b7547935..fc77087df9 100644 --- a/TableProTests/Views/Shared/SelectionAwareTintTests.swift +++ b/TableProTests/Views/Shared/SelectionAwareTintTests.swift @@ -12,21 +12,21 @@ import Testing struct SelectionAwareTintTests { @Test("A prominent selection background takes the selected-content colour") func prominentBackgroundUsesSelectedContentColor() { - let resolved = SelectionAwareTintResolver.color(standard: .accentColor, prominence: .increased) + let resolved = SelectionAwareTintResolver.color(standard: .accentColor, isProminent: true) #expect(resolved == .emphasizedSelectionLabel) } @Test("A standard background keeps the tint") func standardBackgroundKeepsTint() { - let resolved = SelectionAwareTintResolver.color(standard: .accentColor, prominence: .standard) + let resolved = SelectionAwareTintResolver.color(standard: .accentColor, isProminent: false) #expect(resolved == .accentColor) } @Test("A secondary tint follows the same rule, so it never sits grey on the fill") func secondaryTintAlsoFlips() { - #expect(SelectionAwareTintResolver.color(standard: .secondary, prominence: .increased) == .emphasizedSelectionLabel) - #expect(SelectionAwareTintResolver.color(standard: .secondary, prominence: .standard) == .secondary) + #expect(SelectionAwareTintResolver.color(standard: .secondary, isProminent: true) == .emphasizedSelectionLabel) + #expect(SelectionAwareTintResolver.color(standard: .secondary, isProminent: false) == .secondary) } } diff --git a/docs/development/setup.mdx b/docs/development/setup.mdx index ff6e3bfef3..6b6d9c47fa 100644 --- a/docs/development/setup.mdx +++ b/docs/development/setup.mdx @@ -6,7 +6,7 @@ description: Clone, configure signing, and build TablePro in Xcode The Xcode project is generated and the static libraries are downloaded. A fresh clone carries neither, so two scripts run before anything opens in Xcode. Xcode itself has to be 26.0 or newer: the app calls SwiftUI's `glassEffect(_:in:)` behind `if #available(macOS 26.0, *)`, and that symbol -ships in the macOS 26 SDK. The deployment target is macOS 14.4, which is what the built app +ships in the macOS 26 SDK. The deployment target stays at macOS 13.0, which is what the built app runs on, not what you build on. ## Prerequisites diff --git a/docs/features/overview.mdx b/docs/features/overview.mdx index 33444cbb9d..845e61b81f 100644 --- a/docs/features/overview.mdx +++ b/docs/features/overview.mdx @@ -9,7 +9,7 @@ First time here: [Quick Start](/quickstart) gets you from install to a query wit - Homebrew or DMG, on macOS 14.4+. + Homebrew or DMG, on macOS 13.0+. Connect and run a query, sample database included. diff --git a/docs/index.mdx b/docs/index.mdx index 7e8185ce97..97e7e00175 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -14,7 +14,7 @@ Every engine, the SQL editor, the data grid, import, export, and the AI assistan - Homebrew or the DMG, on macOS 14.4 and later. + Homebrew or the DMG, on macOS 13 and later. A first connection and a first query, or the bundled sample database if you have no server. diff --git a/docs/installation.mdx b/docs/installation.mdx index f43b294635..d6dfc0e60d 100644 --- a/docs/installation.mdx +++ b/docs/installation.mdx @@ -1,13 +1,13 @@ --- title: Installation -description: Install TablePro via Homebrew or DMG on macOS 14.4+ +description: Install TablePro via Homebrew or DMG on macOS 13.0+ --- Homebrew is the short route: `brew install --cask tablepro` picks the build that matches your Mac. Everyone else takes the DMG, and that is the one route where you match the architecture yourself. ## System requirements -- **macOS**: 14.0 (Sonoma) or later +- **macOS**: 13.0 (Ventura) or later - **Architecture**: Apple Silicon (M1 or later) or Intel x86_64 Native Apple frameworks only, so there is no Java or .NET runtime to install first. The download is about 20 MB. diff --git a/docs/troubleshooting.mdx b/docs/troubleshooting.mdx index 2543d86668..98217eac57 100644 --- a/docs/troubleshooting.mdx +++ b/docs/troubleshooting.mdx @@ -21,7 +21,7 @@ A failure that happens on one engine only is on that engine's page, which ends w ## The app will not open - **Wrong architecture**: assets are per-architecture, `TablePro--arm64.dmg` and the `x86_64` one. Check yours under **Apple menu > About This Mac**. -- **Too old a macOS**: 14.0 (Sonoma) is the minimum. +- **Too old a macOS**: 13.0 (Ventura) is the minimum. - **macOS refuses to open it**: the copy was damaged or stripped of its signature. Confirm with `spctl -a -vvv /Applications/TablePro.app`, then re-download or run `brew install --cask tablepro`. - **Opens then quits**: attach the crash report from Console.app to an issue. - **Opens straight into a connection**: startup is on **Reopen Last Session**. Switch to **Show Welcome Screen** in **Settings > General**. diff --git a/project.yml b/project.yml index f69370aba0..4afc7cd182 100644 --- a/project.yml +++ b/project.yml @@ -15,7 +15,7 @@ options: transitivelyLinkDependencies: false localPackagesGroup: Packages deploymentTarget: - macOS: "14.4" + macOS: "13.0" configs: Debug: debug diff --git a/scripts/create-openssl-dylibs.sh b/scripts/create-openssl-dylibs.sh index 5ddbd5e55c..9cde7e3df4 100755 --- a/scripts/create-openssl-dylibs.sh +++ b/scripts/create-openssl-dylibs.sh @@ -4,7 +4,7 @@ set -euo pipefail ARCH="${1:-both}" LIBS_DIR="Libs" OUT_DIR="$LIBS_DIR/dylibs" -MIN_MACOS="14.0" +MIN_MACOS="13.0" mkdir -p "$OUT_DIR"