diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ec48afbf3..f54c99db28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Panel and status colors in the theme editor, covering the results, inspector, structure, compare and query plan panes. - Themes that name a system color for a slot, so the built-in themes keep the system's own contrast settings. - Reason shown in Settings > Appearance for a theme file that could not be loaded. +- Install Parquet… in the Export dialog's format menu when the Parquet plugin is not installed. ### Changed diff --git a/TablePro/Core/Plugins/MissingDriverPluginPrompt.swift b/TablePro/Core/Plugins/MissingDriverPluginPrompt.swift index 6e4bbffe4c..f3417b8713 100644 --- a/TablePro/Core/Plugins/MissingDriverPluginPrompt.swift +++ b/TablePro/Core/Plugins/MissingDriverPluginPrompt.swift @@ -37,20 +37,15 @@ internal enum MissingDriverPluginPrompt { ) guard confirmed else { return false } - let presenter = PluginInstallProgressPresenter() - presenter.begin(title: String(format: String(localized: "Downloading the %@ plugin…"), displayName)) - do { - try await PluginManager.shared.installMissingPlugin(for: type) { fraction in - presenter.update(fraction: fraction) + try await PluginInstallProgressPresenter.run( + title: String(format: String(localized: "Downloading the %@ plugin…"), displayName) + ) { progress in + try await PluginManager.shared.installMissingPlugin(for: type, progress: progress) } - presenter.end() logger.info("Installed \(type.rawValue, privacy: .public) to open a file") return true } catch { - /// Ended before the failure is presented, because both are sheets on the same window - /// and the second would queue behind the first. - presenter.end() logger.error("Install failed for \(type.rawValue, privacy: .public): \(error.publicLogShape, privacy: .public)") AlertHelper.showErrorSheet( title: String(localized: "Plugin Installation Failed"), diff --git a/TablePro/Core/Plugins/MissingExportFormatPrompt.swift b/TablePro/Core/Plugins/MissingExportFormatPrompt.swift new file mode 100644 index 0000000000..4ddb2da3f0 --- /dev/null +++ b/TablePro/Core/Plugins/MissingExportFormatPrompt.swift @@ -0,0 +1,43 @@ +// +// MissingExportFormatPrompt.swift +// TablePro +// + +import AppKit +import os + +@MainActor +internal enum MissingExportFormatPrompt { + nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "MissingExportFormatPrompt") + + internal static func ensureInstalled(_ format: InstallableExportFormat, window: NSWindow?) async -> Bool { + let confirmed = await AlertHelper.confirm( + title: String(format: String(localized: "Install the %@ plugin?"), format.displayName), + message: String(localized: "TablePro writes this format with a plugin it downloads from the plugin registry."), + confirmButton: String(localized: "Install"), + window: window + ) + guard confirmed else { return false } + + do { + try await PluginInstallProgressPresenter.run( + title: String(format: String(localized: "Downloading the %@ plugin…"), format.displayName), + window: window + ) { progress in + try await PluginManager.shared.installMissingExportFormat(format, progress: progress) + } + logger.info("Installed the \(format.formatId, privacy: .public) export format") + return true + } catch { + logger.error( + "Install failed for \(format.formatId, privacy: .public): \(error.publicLogShape, privacy: .public)" + ) + AlertHelper.showErrorSheet( + title: String(localized: "Plugin Installation Failed"), + message: error.localizedDescription, + window: window + ) + return false + } + } +} diff --git a/TablePro/Core/Plugins/PluginInstallProgressPresenter.swift b/TablePro/Core/Plugins/PluginInstallProgressPresenter.swift index 9ef6e10cd8..100cb512bb 100644 --- a/TablePro/Core/Plugins/PluginInstallProgressPresenter.swift +++ b/TablePro/Core/Plugins/PluginInstallProgressPresenter.swift @@ -25,15 +25,27 @@ internal final class PluginInstallProgressPresenter { internal init() {} + // Ended before the caller presents a failure, since a second sheet on the window would queue. + internal static func run( + title: String, + window: NSWindow? = nil, + _ install: (_ progress: @escaping @MainActor @Sendable (Double) -> Void) async throws -> Void + ) async throws { + let presenter = PluginInstallProgressPresenter() + presenter.begin(title: title, window: window) + defer { presenter.end() } + try await install { fraction in presenter.update(fraction: fraction) } + } + /// Presented as a sheet on the window the user was working in, and as a free-standing panel /// when there is none, which is how a Finder open before any window exists reaches the screen. - internal func begin(title: String) { + internal func begin(title: String, window: NSWindow? = nil) { guard panel == nil else { return } label.stringValue = title let panel = makePanel() self.panel = panel - guard let parent = AlertHelper.resolveWindow(nil) else { + guard let parent = AlertHelper.resolveWindow(window) else { panel.center() panel.makeKeyAndOrderFront(nil) return diff --git a/TablePro/Core/Plugins/PluginManager+Registration.swift b/TablePro/Core/Plugins/PluginManager+Registration.swift index 62752fd217..30b482a8d1 100644 --- a/TablePro/Core/Plugins/PluginManager+Registration.swift +++ b/TablePro/Core/Plugins/PluginManager+Registration.swift @@ -770,12 +770,35 @@ extension PluginManager { } } + let entry = try await installFromManifest(registryClient: registryClient, progress: progress) { manifest in + Self.registryPlugin(forTypeId: pluginTypeId, in: manifest) + } + Self.logger.info("Installed missing plugin '\(entry.name)' for database type '\(databaseType.rawValue)'") + } + + func installMissingExportFormat( + _ format: InstallableExportFormat, + registryClient: RegistryClient = .shared, + progress: @escaping @MainActor @Sendable (Double) -> Void + ) async throws { + guard !plugins.contains(where: { $0.id == format.registryPluginId }) else { return } + let entry = try await installFromManifest(registryClient: registryClient, progress: progress) { manifest in + manifest?.plugins.first { $0.id == format.registryPluginId } + } + Self.logger.info("Installed missing plugin '\(entry.name)' for export format '\(format.formatId)'") + } + + private func installFromManifest( + registryClient: RegistryClient, + progress: @escaping @MainActor @Sendable (Double) -> Void, + lookup: (RegistryManifest?) -> RegistryPlugin? + ) async throws -> PluginEntry { await registryClient.ensureManifest(.ifStale) - var registryPlugin = Self.registryPlugin(forTypeId: pluginTypeId, in: registryClient.manifest) + var registryPlugin = lookup(registryClient.manifest) if registryPlugin == nil { await registryClient.ensureManifest(.mustBeCurrent) - registryPlugin = Self.registryPlugin(forTypeId: pluginTypeId, in: registryClient.manifest) + registryPlugin = lookup(registryClient.manifest) } guard let registryPlugin else { @@ -785,10 +808,7 @@ extension PluginManager { throw PluginError.notFound } - /// Published to the tracker as well as to the caller's handler. `PluginInstallStatusRow` - /// is built to draw the fraction and reaches it only through the tracker, so without this - /// every install started from a connection or a file fell back to its indeterminate - /// spinner while the determinate bar beside it was never fed. + // PluginInstallStatusRow reads the fraction only through the tracker. let tracker = PluginInstallTracker.shared tracker.beginInstall(pluginId: registryPlugin.id) do { @@ -801,7 +821,7 @@ extension PluginManager { } ) tracker.completeInstall(pluginId: registryPlugin.id) - Self.logger.info("Installed missing plugin '\(entry.name)' for database type '\(databaseType.rawValue)'") + return entry } catch { tracker.failInstall(pluginId: registryPlugin.id, error: error.localizedDescription) throw error diff --git a/TablePro/Core/Plugins/PluginSettingsSnapshot.swift b/TablePro/Core/Plugins/PluginSettingsSnapshot.swift index a228bcd1e6..01833fc981 100644 --- a/TablePro/Core/Plugins/PluginSettingsSnapshot.swift +++ b/TablePro/Core/Plugins/PluginSettingsSnapshot.swift @@ -22,6 +22,12 @@ struct PluginSettingsSnapshot { } } + mutating func include(_ plugin: any SettablePluginDiscoverable) { + guard !entries.contains(where: { $0.plugin === plugin }), + let data = plugin.snapshotSettingsData() else { return } + entries.append((plugin, data)) + } + mutating func recapture(_ plugin: any SettablePluginDiscoverable) { guard let index = entries.firstIndex(where: { $0.plugin === plugin }), let data = plugin.snapshotSettingsData() else { return } diff --git a/TablePro/Core/Services/Export/ExportFormatCatalog.swift b/TablePro/Core/Services/Export/ExportFormatCatalog.swift index 750fbc323d..03f5386b0b 100644 --- a/TablePro/Core/Services/Export/ExportFormatCatalog.swift +++ b/TablePro/Core/Services/Export/ExportFormatCatalog.swift @@ -6,11 +6,30 @@ import Foundation import TableProPluginKit +internal struct InstallableExportFormat: Equatable, Sendable { + internal let formatId: String + internal let displayName: String + internal let registryPluginId: String +} + internal enum ExportFormatCatalog { private static let displayOrder = [ "csv", "json", "sql", "xlsx", "md", "parquet", "mql" ] + // The registry manifest names no format id, so the app keeps the one mapping it offers. + internal static let installable = [ + InstallableExportFormat( + formatId: "parquet", + displayName: "Parquet", + registryPluginId: "com.TablePro.ParquetExportPlugin" + ) + ] + + internal static func notInstalled(installedFormatIds: Set) -> [InstallableExportFormat] { + installable.filter { !installedFormatIds.contains($0.formatId) } + } + internal static func available( _ plugins: [any ExportFormatPlugin], forDatabaseTypeId databaseTypeId: String diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 60475976fd..96c5dde67d 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -92513,6 +92513,9 @@ } } } + }, + "Install %@…" : { + }, "Install it with “brew install libpq” and link it." : { "localizations" : { @@ -92679,6 +92682,9 @@ } } } + }, + "Install the %@ plugin?" : { + }, "Installed" : { "localizations" : { @@ -176029,6 +176035,9 @@ } } } + }, + "TablePro writes this format with a plugin it downloads from the plugin registry." : { + }, "TABLEPRO_BEANCOUNT_PYTHON points to a Python executable that cannot import beancount at %@" : { "extractionState" : "manual", diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index 77a14787c9..372bedfc8e 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -222,6 +222,40 @@ struct ExportDialog: View { pluginManager.exportPlugin(forFormat: config.formatId) } + private var formatsToOffer: [InstallableExportFormat] { + let installed = Set(pluginManager.allExportPlugins().map { type(of: $0).formatId }) + return ExportFormatCatalog.notInstalled(installedFormatIds: installed) + } + + private static func offerTag(for format: InstallableExportFormat) -> String { + "install:\(format.formatId)" + } + + // Choosing an offered format asks to install it and keeps the current format until it lands. + private var formatSelection: Binding { + Binding( + get: { config.formatId }, + set: { newValue in + guard let format = formatsToOffer.first(where: { Self.offerTag(for: $0) == newValue }) else { + config.formatId = newValue + return + } + Task { await installOfferedFormat(format) } + } + ) + } + + @MainActor + private func installOfferedFormat(_ format: InstallableExportFormat) async { + guard await MissingExportFormatPrompt.ensureInstalled(format, window: hostWindow), + let plugin = pluginManager.exportPlugin(forFormat: format.formatId) + else { return } + if let settable = plugin as? any SettablePluginDiscoverable { + settingsSnapshot?.include(settable) + } + config.formatId = format.formatId + } + private var currentOptionColumnCount: Int { guard let plugin = currentPlugin else { return 0 } return type(of: plugin).perTableOptionColumns.count @@ -427,7 +461,7 @@ struct ExportDialog: View { .frame(maxWidth: .infinity, alignment: .center) } - if availableFormats.isEmpty { + if availableFormats.isEmpty && formatsToOffer.isEmpty { HStack { Spacer() Text("No export formats available.") @@ -439,12 +473,20 @@ struct ExportDialog: View { HStack { Spacer() - Picker(String(localized: "Format"), selection: $config.formatId) { + Picker(String(localized: "Format"), selection: formatSelection) { ForEach(availableFormatIds, id: \.self) { formatId in if let plugin = pluginManager.exportPlugin(forFormat: formatId) { Text(type(of: plugin).formatDisplayName).tag(formatId) } } + let offered = formatsToOffer + if !offered.isEmpty { + Divider() + ForEach(offered, id: \.formatId) { format in + Text(String(format: String(localized: "Install %@…"), format.displayName)) + .tag(Self.offerTag(for: format)) + } + } } .labelsHidden() diff --git a/TableProTests/Core/Export/ExportFormatCatalogTests.swift b/TableProTests/Core/Export/ExportFormatCatalogTests.swift index 77baf70430..326db9491e 100644 --- a/TableProTests/Core/Export/ExportFormatCatalogTests.swift +++ b/TableProTests/Core/Export/ExportFormatCatalogTests.swift @@ -73,7 +73,6 @@ private final class StubAardvark: StubExportFormat, @unchecked Sendable { } struct ExportFormatCatalogTests { - private func ids(_ plugins: [any ExportFormatPlugin]) -> [String] { plugins.map { type(of: $0).formatId } } @@ -118,4 +117,13 @@ struct ExportFormatCatalogTests { #expect(forwards == backwards) #expect(forwards == ["csv", "sql", "parquet", "zebra"]) } + + @Test("Parquet is offered for install until it is installed") + func parquetIsOfferedUntilInstalled() { + let offered = ExportFormatCatalog.notInstalled(installedFormatIds: ["csv", "sql"]) + #expect(offered.map(\.formatId) == ["parquet"]) + #expect(offered.first?.registryPluginId == "com.TablePro.ParquetExportPlugin") + + #expect(ExportFormatCatalog.notInstalled(installedFormatIds: ["csv", "parquet"]).isEmpty) + } } diff --git a/TableProTests/Core/Plugins/PluginSettingsSnapshotTests.swift b/TableProTests/Core/Plugins/PluginSettingsSnapshotTests.swift index 389ce75a1d..3b97c6c47b 100644 --- a/TableProTests/Core/Plugins/PluginSettingsSnapshotTests.swift +++ b/TableProTests/Core/Plugins/PluginSettingsSnapshotTests.swift @@ -5,9 +5,9 @@ import Foundation import SwiftUI +@testable import TablePro import TableProPluginKit import Testing -@testable import TablePro @Suite("PluginSettingsSnapshot", .serialized) struct PluginSettingsSnapshotTests { @@ -90,4 +90,32 @@ struct PluginSettingsSnapshotTests { #expect(plugin.settings == TestOptions(flag: true, count: 3)) } + + @Test("A plugin installed after the capture is rolled back too") + func includeAddsALaterPlugin() { + defer { cleanup() } + var snapshot = PluginSettingsSnapshot(plugins: []) + let plugin = TestPlugin() + plugin.settings = TestOptions(flag: true, count: 4) + + snapshot.include(plugin) + plugin.settings = TestOptions(flag: false, count: 8) + snapshot.restore() + + #expect(plugin.settings == TestOptions(flag: true, count: 4)) + } + + @Test("Including a captured plugin keeps its original baseline") + func includeKeepsTheFirstBaseline() { + defer { cleanup() } + let plugin = TestPlugin() + plugin.settings = TestOptions(flag: true, count: 1) + var snapshot = PluginSettingsSnapshot(plugins: [plugin]) + + plugin.settings = TestOptions(flag: false, count: 6) + snapshot.include(plugin) + snapshot.restore() + + #expect(plugin.settings == TestOptions(flag: true, count: 1)) + } } diff --git a/TableProTests/Core/Plugins/RegistryClientFreshnessTests.swift b/TableProTests/Core/Plugins/RegistryClientFreshnessTests.swift index 8ab4fe11df..48d33bc437 100644 --- a/TableProTests/Core/Plugins/RegistryClientFreshnessTests.swift +++ b/TableProTests/Core/Plugins/RegistryClientFreshnessTests.swift @@ -236,6 +236,60 @@ struct RegistryClientFreshnessTests { } } + @Test("an offered export format is found in the registry by its plugin id") + func exportFormatResolvesByRegistryId() async throws { + let fresh = manifestData(includingTestPlugin: true) + MockRegistryProtocol.reset(responses: [(200, fresh), (200, fresh), (200, fresh)]) + let (client, defaults, tempDir) = try makeEnvironment() + let manager = PluginManager( + userDefaults: defaults, + builtInPluginsURL: nil, + userPluginsDir: tempDir.appendingPathComponent("Plugins", isDirectory: true) + ) + let format = InstallableExportFormat( + formatId: "freshness", + displayName: "Freshness", + registryPluginId: "com.test.freshness-driver" + ) + + do { + try await manager.installMissingExportFormat(format, registryClient: client) { _ in } + Issue.record("Expected install to fail at compatibility validation") + } catch let error as PluginError { + guard case .incompatibleWithCurrentApp = error else { + Issue.record("Unexpected error: \(error)") + return + } + } + } + + @Test("an offered export format missing from the registry reports not found") + func exportFormatMissingFromRegistry() async throws { + let empty = manifestData(includingTestPlugin: false) + MockRegistryProtocol.reset(responses: [(200, empty), (200, empty)]) + let (client, defaults, tempDir) = try makeEnvironment() + let manager = PluginManager( + userDefaults: defaults, + builtInPluginsURL: nil, + userPluginsDir: tempDir.appendingPathComponent("Plugins", isDirectory: true) + ) + let format = InstallableExportFormat( + formatId: "freshness", + displayName: "Freshness", + registryPluginId: "com.test.freshness-driver" + ) + + do { + try await manager.installMissingExportFormat(format, registryClient: client) { _ in } + Issue.record("Expected install to fail") + } catch let error as PluginError { + guard case .notFound = error else { + Issue.record("Unexpected error: \(error)") + return + } + } + } + @Test("registryPlugin resolves by database type id") func registryPluginLookup() throws { let manifest = try JSONDecoder().decode(RegistryManifest.self, from: manifestData(includingTestPlugin: true))