Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 4 additions & 9 deletions TablePro/Core/Plugins/MissingDriverPluginPrompt.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
43 changes: 43 additions & 0 deletions TablePro/Core/Plugins/MissingExportFormatPrompt.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
16 changes: 14 additions & 2 deletions TablePro/Core/Plugins/PluginInstallProgressPresenter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 27 additions & 7 deletions TablePro/Core/Plugins/PluginManager+Registration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions TablePro/Core/Plugins/PluginSettingsSnapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
19 changes: 19 additions & 0 deletions TablePro/Core/Services/Export/ExportFormatCatalog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>) -> [InstallableExportFormat] {
installable.filter { !installedFormatIds.contains($0.formatId) }
}

internal static func available(
_ plugins: [any ExportFormatPlugin],
forDatabaseTypeId databaseTypeId: String
Expand Down
9 changes: 9 additions & 0 deletions TablePro/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -92513,6 +92513,9 @@
}
}
}
},
"Install %@…" : {

},
"Install it with “brew install libpq” and link it." : {
"localizations" : {
Expand Down Expand Up @@ -92679,6 +92682,9 @@
}
}
}
},
"Install the %@ plugin?" : {

},
"Installed" : {
"localizations" : {
Expand Down Expand Up @@ -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",
Expand Down
46 changes: 44 additions & 2 deletions TablePro/Views/Export/ExportDialog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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
Expand Down Expand Up @@ -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.")
Expand All @@ -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()

Expand Down
10 changes: 9 additions & 1 deletion TableProTests/Core/Export/ExportFormatCatalogTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
Expand Down Expand Up @@ -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)
}
}
30 changes: 29 additions & 1 deletion TableProTests/Core/Plugins/PluginSettingsSnapshotTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@

import Foundation
import SwiftUI
@testable import TablePro
import TableProPluginKit
import Testing
@testable import TablePro

@Suite("PluginSettingsSnapshot", .serialized)
struct PluginSettingsSnapshotTests {
Expand Down Expand Up @@ -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))
}
}
Loading
Loading