From a87029ebfa85a1567faf09bff6dd6ce8cb14bd99 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 5 Jun 2026 23:41:33 +0700 Subject: [PATCH 1/2] feat(plugins): remember last-used export and import settings (#1591) --- CHANGELOG.md | 1 + Plugins/CSVExportPlugin/CSVExportPlugin.swift | 4 + .../JSONExportPlugin/JSONExportPlugin.swift | 4 + .../JSONImportPlugin/JSONImportPlugin.swift | 4 + Plugins/MQLExportPlugin/MQLExportPlugin.swift | 4 + Plugins/SQLExportPlugin/SQLExportPlugin.swift | 4 + Plugins/SQLImportPlugin/SQLImportPlugin.swift | 4 + .../TableProPluginKit/SettablePlugin.swift | 20 +++++ .../XLSXExportPlugin/XLSXExportPlugin.swift | 4 + .../Core/Storage/ExportDialogStorage.swift | 41 +++++++++ TablePro/Views/Export/ExportDialog.swift | 60 ++++++++++++- TablePro/Views/Import/ImportDialog.swift | 57 +++++++++++- .../Core/Plugins/PluginSettingsTests.swift | 87 +++++++++++++++++++ .../Storage/ExportDialogStorageTests.swift | 80 +++++++++++++++++ docs/features/import-export.mdx | 4 +- 15 files changed, 369 insertions(+), 9 deletions(-) create mode 100644 TablePro/Core/Storage/ExportDialogStorage.swift create mode 100644 TableProTests/Core/Storage/ExportDialogStorageTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 5512607a6..9c1f253e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Redis connects to Amazon ElastiCache with IAM auth (access key, profile, or SSO). TablePro generates the IAM token and uses it as the password; set the AWS region and cache name and enable TLS. (#1567) - AWS SSO connections can sign in from TablePro: when the SSO session has expired, a prompt opens the AWS sign-in page in your browser and refreshes the cached token. (#1567) - Cassandra connects to Amazon Keyspaces with AWS IAM (SigV4) auth, using access keys, a profile, or SSO. Set Authentication to an AWS IAM mode and the region, and enable TLS. (#1567) +- Export and import dialogs reopen with the last-used format, options, and encoding. Option changes are kept only when the export or import completes, so cancelling no longer overwrites your saved settings, and a Reset to Defaults button restores the stock options. (#1591) ### Changed diff --git a/Plugins/CSVExportPlugin/CSVExportPlugin.swift b/Plugins/CSVExportPlugin/CSVExportPlugin.swift index 75db39b12..332a9825c 100644 --- a/Plugins/CSVExportPlugin/CSVExportPlugin.swift +++ b/Plugins/CSVExportPlugin/CSVExportPlugin.swift @@ -33,6 +33,10 @@ final class CSVExportPlugin: ExportFormatPlugin, SettablePlugin { AnyView(CSVExportOptionsView(plugin: self)) } + func resetSettingsToDefaults() { + settings = CSVExportOptions() + } + func export( tables: [PluginExportTable], dataSource: any PluginExportDataSource, diff --git a/Plugins/JSONExportPlugin/JSONExportPlugin.swift b/Plugins/JSONExportPlugin/JSONExportPlugin.swift index 2868e2890..5dc96422e 100644 --- a/Plugins/JSONExportPlugin/JSONExportPlugin.swift +++ b/Plugins/JSONExportPlugin/JSONExportPlugin.swift @@ -30,6 +30,10 @@ final class JSONExportPlugin: ExportFormatPlugin, SettablePlugin { AnyView(JSONExportOptionsView(plugin: self)) } + func resetSettingsToDefaults() { + settings = JSONExportOptions() + } + func export( tables: [PluginExportTable], dataSource: any PluginExportDataSource, diff --git a/Plugins/JSONImportPlugin/JSONImportPlugin.swift b/Plugins/JSONImportPlugin/JSONImportPlugin.swift index 47705f3ab..d5a5cb923 100644 --- a/Plugins/JSONImportPlugin/JSONImportPlugin.swift +++ b/Plugins/JSONImportPlugin/JSONImportPlugin.swift @@ -34,6 +34,10 @@ final class JSONImportPlugin: ImportFormatPlugin, SettablePlugin { AnyView(JSONImportOptionsView(plugin: self)) } + func resetSettingsToDefaults() { + settings = JSONImportOptions() + } + func performImport( source: any PluginImportSource, sink: any PluginImportDataSink, diff --git a/Plugins/MQLExportPlugin/MQLExportPlugin.swift b/Plugins/MQLExportPlugin/MQLExportPlugin.swift index f4f7a686f..90e19d6d8 100644 --- a/Plugins/MQLExportPlugin/MQLExportPlugin.swift +++ b/Plugins/MQLExportPlugin/MQLExportPlugin.swift @@ -45,6 +45,10 @@ final class MQLExportPlugin: ExportFormatPlugin, SettablePlugin { AnyView(MQLExportOptionsView(plugin: self)) } + func resetSettingsToDefaults() { + settings = MQLExportOptions() + } + func export( tables: [PluginExportTable], dataSource: any PluginExportDataSource, diff --git a/Plugins/SQLExportPlugin/SQLExportPlugin.swift b/Plugins/SQLExportPlugin/SQLExportPlugin.swift index 578c31e6d..eaa26b45c 100644 --- a/Plugins/SQLExportPlugin/SQLExportPlugin.swift +++ b/Plugins/SQLExportPlugin/SQLExportPlugin.swift @@ -55,6 +55,10 @@ final class SQLExportPlugin: ExportFormatPlugin, SettablePlugin { AnyView(SQLExportOptionsView(plugin: self)) } + func resetSettingsToDefaults() { + settings = SQLExportOptions() + } + func export( tables: [PluginExportTable], dataSource: any PluginExportDataSource, diff --git a/Plugins/SQLImportPlugin/SQLImportPlugin.swift b/Plugins/SQLImportPlugin/SQLImportPlugin.swift index 453a3405e..9a2d4a464 100644 --- a/Plugins/SQLImportPlugin/SQLImportPlugin.swift +++ b/Plugins/SQLImportPlugin/SQLImportPlugin.swift @@ -33,6 +33,10 @@ final class SQLImportPlugin: ImportFormatPlugin, SettablePlugin { AnyView(SQLImportOptionsView(plugin: self)) } + func resetSettingsToDefaults() { + settings = SQLImportOptions() + } + func performImport( source: any PluginImportSource, sink: any PluginImportDataSink, diff --git a/Plugins/TableProPluginKit/SettablePlugin.swift b/Plugins/TableProPluginKit/SettablePlugin.swift index 2a2f1725c..021a05d48 100644 --- a/Plugins/TableProPluginKit/SettablePlugin.swift +++ b/Plugins/TableProPluginKit/SettablePlugin.swift @@ -9,6 +9,17 @@ import SwiftUI /// Type-erased witness for runtime discovery (needed because SettablePlugin has associated type). public protocol SettablePluginDiscoverable: AnyObject { func settingsView() -> AnyView? + func snapshotSettingsData() -> Data? + func restoreSettingsData(_ data: Data) + func resetSettingsToDefaults() +} + +public extension SettablePluginDiscoverable { + func snapshotSettingsData() -> Data? { nil } + + func restoreSettingsData(_ data: Data) {} + + func resetSettingsToDefaults() {} } /// Opt-in protocol for plugins with user-configurable settings. @@ -25,6 +36,15 @@ public protocol SettablePlugin: SettablePluginDiscoverable { public extension SettablePlugin { func settingsView() -> AnyView? { nil } + func snapshotSettingsData() -> Data? { + try? JSONEncoder().encode(settings) + } + + func restoreSettingsData(_ data: Data) { + guard let restored = try? JSONDecoder().decode(Settings.self, from: data) else { return } + settings = restored + } + func loadSettings() { let storage = PluginSettingsStorage(pluginId: Self.settingsStorageId) if let saved = storage.load(Settings.self) { diff --git a/Plugins/XLSXExportPlugin/XLSXExportPlugin.swift b/Plugins/XLSXExportPlugin/XLSXExportPlugin.swift index 68e37cae1..ed6030fec 100644 --- a/Plugins/XLSXExportPlugin/XLSXExportPlugin.swift +++ b/Plugins/XLSXExportPlugin/XLSXExportPlugin.swift @@ -30,6 +30,10 @@ final class XLSXExportPlugin: ExportFormatPlugin, SettablePlugin { AnyView(XLSXExportOptionsView(plugin: self)) } + func resetSettingsToDefaults() { + settings = XLSXExportOptions() + } + private static let maxRowsPerSheet = 1_048_576 func export( diff --git a/TablePro/Core/Storage/ExportDialogStorage.swift b/TablePro/Core/Storage/ExportDialogStorage.swift new file mode 100644 index 000000000..f5fd2fa2d --- /dev/null +++ b/TablePro/Core/Storage/ExportDialogStorage.swift @@ -0,0 +1,41 @@ +// +// ExportDialogStorage.swift +// TablePro +// + +import Foundation + +final class ExportDialogStorage { + static let shared = ExportDialogStorage() + + private let defaults: UserDefaults + + private enum Keys { + static let lastExportFormatId = "com.TablePro.export.dialog.lastFormatId" + static let lastImportEncoding = "com.TablePro.import.dialog.lastEncoding" + } + + init(userDefaults: UserDefaults = .standard) { + self.defaults = userDefaults + } + + func loadLastExportFormatId() -> String? { + defaults.string(forKey: Keys.lastExportFormatId) + } + + func saveLastExportFormatId(_ formatId: String) { + defaults.set(formatId, forKey: Keys.lastExportFormatId) + } + + func loadLastImportEncoding() -> ImportEncoding { + guard let rawValue = defaults.string(forKey: Keys.lastImportEncoding), + let encoding = ImportEncoding(rawValue: rawValue) else { + return .utf8 + } + return encoding + } + + func saveLastImportEncoding(_ encoding: ImportEncoding) { + defaults.set(encoding.rawValue, forKey: Keys.lastImportEncoding) + } +} diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index d826ec29f..4c9e23fd7 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -26,6 +26,8 @@ struct ExportDialog: View { @State private var showProgressDialog = false @State private var showSuccessDialog = false @State private var exportedFileURL: URL? + @State private var settingsSnapshots: [String: Data] = [:] + @State private var exportSucceeded = false // MARK: - User Preferences @@ -95,10 +97,18 @@ struct ExportDialog: View { .background(Color(nsColor: .windowBackgroundColor)) .onAppear { let available = availableFormats - if !available.contains(where: { type(of: $0).formatId == config.formatId }) { - if let first = available.first { - config.formatId = type(of: first).formatId - } + if let lastFormatId = ExportDialogStorage.shared.loadLastExportFormatId(), + available.contains(where: { type(of: $0).formatId == lastFormatId }) { + config.formatId = lastFormatId + } else if !available.contains(where: { type(of: $0).formatId == config.formatId }), + let first = available.first { + config.formatId = type(of: first).formatId + } + captureSettingsSnapshots() + } + .onDisappear { + if !exportSucceeded { + restoreSettingsSnapshots() } } .onChange(of: config.formatId) { @@ -329,6 +339,16 @@ struct ExportDialog: View { if let settable = currentPlugin as? any SettablePluginDiscoverable, let optionsView = settable.settingsView() { optionsView + + HStack { + Spacer() + Button("Reset to Defaults") { + resetCurrentFormatSettings() + } + .buttonStyle(.link) + .font(.callout) + } + .padding(.top, 8) } } .padding(.horizontal, 16) @@ -516,6 +536,36 @@ struct ExportDialog: View { // MARK: - Actions + private func captureSettingsSnapshots() { + var snapshots: [String: Data] = [:] + for plugin in availableFormats { + guard let settable = plugin as? any SettablePluginDiscoverable, + let data = settable.snapshotSettingsData() else { continue } + snapshots[type(of: plugin).formatId] = data + } + settingsSnapshots = snapshots + } + + private func restoreSettingsSnapshots() { + for (formatId, data) in settingsSnapshots { + let plugin = PluginManager.shared.exportPlugin(forFormat: formatId) + (plugin as? any SettablePluginDiscoverable)?.restoreSettingsData(data) + } + settingsSnapshots.removeAll() + } + + private func resetCurrentFormatSettings() { + guard let settable = currentPlugin as? any SettablePluginDiscoverable else { return } + settable.resetSettingsToDefaults() + settingsSnapshots[config.formatId] = settable.snapshotSettingsData() + } + + private func recordSuccessfulExport() { + exportSucceeded = true + ExportDialogStorage.shared.saveLastExportFormatId(config.formatId) + settingsSnapshots.removeAll() + } + /// Instantly populate the current database from sidebar tables (no network). private func populateFromSidebarTables() { guard !sidebarTables.isEmpty else { return } @@ -803,6 +853,7 @@ struct ExportDialog: View { showProgressDialog = false isExporting = false + recordSuccessfulExport() if hideSuccessDialog { isPresented = false @@ -847,6 +898,7 @@ struct ExportDialog: View { showProgressDialog = false isExporting = false + recordSuccessfulExport() if hideSuccessDialog { isPresented = false diff --git a/TablePro/Views/Import/ImportDialog.swift b/TablePro/Views/Import/ImportDialog.swift index fea4fcb77..9af86b4c2 100644 --- a/TablePro/Views/Import/ImportDialog.swift +++ b/TablePro/Views/Import/ImportDialog.swift @@ -23,6 +23,7 @@ struct ImportDialog: View { self.connection = connection self.initialFileURL = initialFileURL self._selectedFormatId = State(initialValue: initialFormatId) + self._selectedEncoding = State(initialValue: ExportDialogStorage.shared.loadLastImportEncoding()) } // MARK: - State @@ -32,8 +33,10 @@ struct ImportDialog: View { @State private var fileSize: Int64 = 0 @State private var statementCount: Int = 0 @State private var isCountingStatements = false - @State private var selectedEncoding: ImportEncoding = .utf8 + @State private var selectedEncoding: ImportEncoding @State private var selectedFormatId: String = "sql" + @State private var settingsSnapshots: [String: Data] = [:] + @State private var importSucceeded = false @State private var showProgressDialog = false @State private var showSuccessDialog = false @State private var showErrorDialog = false @@ -83,6 +86,7 @@ struct ImportDialog: View { selectedFormatId = type(of: first).formatId } } + captureSettingsSnapshots() } .onExitCommand { if !(importService?.state.isImporting ?? false) { @@ -99,6 +103,9 @@ struct ImportDialog: View { countStatementsTask?.cancel() importTask?.cancel() cleanupTempFiles() + if !importSucceeded { + restoreSettingsSnapshots() + } } .sheet(isPresented: $showProgressDialog) { if let service = importService { @@ -237,9 +244,19 @@ struct ImportDialog: View { private var optionsView: some View { VStack(alignment: .leading, spacing: 12) { - Text("Options") - .font(.callout.weight(.semibold)) - .foregroundStyle(.primary) + HStack { + Text("Options") + .font(.callout.weight(.semibold)) + .foregroundStyle(.primary) + + Spacer() + + Button("Reset to Defaults") { + resetOptionsToDefaults() + } + .buttonStyle(.link) + .font(.callout) + } VStack(alignment: .leading, spacing: 12) { // Encoding picker (always shown, independent of plugin) @@ -298,6 +315,37 @@ struct ImportDialog: View { // MARK: - Actions + private func captureSettingsSnapshots() { + var snapshots: [String: Data] = [:] + for plugin in availableFormats { + guard let settable = plugin as? any SettablePluginDiscoverable, + let data = settable.snapshotSettingsData() else { continue } + snapshots[type(of: plugin).formatId] = data + } + settingsSnapshots = snapshots + } + + private func restoreSettingsSnapshots() { + for (formatId, data) in settingsSnapshots { + let plugin = PluginManager.shared.importPlugin(forFormat: formatId) + (plugin as? any SettablePluginDiscoverable)?.restoreSettingsData(data) + } + settingsSnapshots.removeAll() + } + + private func resetOptionsToDefaults() { + selectedEncoding = .utf8 + guard let settable = currentPlugin as? any SettablePluginDiscoverable else { return } + settable.resetSettingsToDefaults() + settingsSnapshots[selectedFormatId] = settable.snapshotSettingsData() + } + + private func recordSuccessfulImport() { + importSucceeded = true + ExportDialogStorage.shared.saveLastImportEncoding(selectedEncoding) + settingsSnapshots.removeAll() + } + @MainActor private func selectFile() async { guard let window = NSApp.keyWindow ?? NSApp.mainWindow else { return } @@ -432,6 +480,7 @@ struct ImportDialog: View { await MainActor.run { showProgressDialog = false importResult = result + recordSuccessfulImport() showSuccessDialog = true } } catch is PluginImportCancellationError { diff --git a/TableProTests/Core/Plugins/PluginSettingsTests.swift b/TableProTests/Core/Plugins/PluginSettingsTests.swift index 21d49b61b..fedc5ba45 100644 --- a/TableProTests/Core/Plugins/PluginSettingsTests.swift +++ b/TableProTests/Core/Plugins/PluginSettingsTests.swift @@ -130,6 +130,93 @@ struct PluginSettingsStorageTests { } } +@Suite("SettablePlugin snapshot and restore", .serialized) +struct SettablePluginSnapshotTests { + private struct TestOptions: Codable, Equatable { + var flag = false + var count = 0 + } + + private final class TestSettablePlugin: SettablePlugin { + static let settingsStorageId = "test.settable.snapshot" + + var settings = TestOptions() { + didSet { saveSettings() } + } + + func resetSettingsToDefaults() { + settings = TestOptions() + } + } + + private func cleanup() { + PluginSettingsStorage(pluginId: TestSettablePlugin.settingsStorageId).removeAll() + } + + @Test("snapshotSettingsData encodes current settings") + func snapshotEncodesCurrentSettings() throws { + defer { cleanup() } + let plugin = TestSettablePlugin() + plugin.settings = TestOptions(flag: true, count: 7) + + let data = try #require(plugin.snapshotSettingsData()) + let decoded = try JSONDecoder().decode(TestOptions.self, from: data) + + #expect(decoded == plugin.settings) + } + + @Test("restoreSettingsData restores settings and persists the restored value") + func restoreRevertsSettingsAndStorage() throws { + defer { cleanup() } + let plugin = TestSettablePlugin() + plugin.settings = TestOptions(flag: true, count: 1) + let snapshot = try #require(plugin.snapshotSettingsData()) + + plugin.settings = TestOptions(flag: false, count: 99) + plugin.restoreSettingsData(snapshot) + + #expect(plugin.settings == TestOptions(flag: true, count: 1)) + let storage = PluginSettingsStorage(pluginId: TestSettablePlugin.settingsStorageId) + #expect(storage.load(TestOptions.self) == TestOptions(flag: true, count: 1)) + } + + @Test("restoreSettingsData ignores invalid data") + func restoreIgnoresInvalidData() { + defer { cleanup() } + let plugin = TestSettablePlugin() + plugin.settings = TestOptions(flag: true, count: 5) + + plugin.restoreSettingsData(Data("not json".utf8)) + + #expect(plugin.settings == TestOptions(flag: true, count: 5)) + } + + @Test("resetSettingsToDefaults restores default values") + func resetRestoresDefaults() { + defer { cleanup() } + let plugin = TestSettablePlugin() + plugin.settings = TestOptions(flag: true, count: 42) + + plugin.resetSettingsToDefaults() + + #expect(plugin.settings == TestOptions()) + } + + @Test("snapshot and restore dispatch through the type-erased protocol") + func typeErasedDispatch() throws { + defer { cleanup() } + let plugin = TestSettablePlugin() + plugin.settings = TestOptions(flag: true, count: 3) + let discoverable: any SettablePluginDiscoverable = plugin + + let snapshot = try #require(discoverable.snapshotSettingsData()) + plugin.settings = TestOptions(flag: false, count: 0) + discoverable.restoreSettingsData(snapshot) + + #expect(plugin.settings == TestOptions(flag: true, count: 3)) + } +} + @Suite("PluginCapability") struct PluginCapabilityTests { diff --git a/TableProTests/Core/Storage/ExportDialogStorageTests.swift b/TableProTests/Core/Storage/ExportDialogStorageTests.swift new file mode 100644 index 000000000..148eac721 --- /dev/null +++ b/TableProTests/Core/Storage/ExportDialogStorageTests.swift @@ -0,0 +1,80 @@ +// +// ExportDialogStorageTests.swift +// TableProTests +// + +import Foundation +import Testing +@testable import TablePro + +@Suite("ExportDialogStorage") +struct ExportDialogStorageTests { + private let suiteName = "com.TablePro.tests.exportDialog.\(UUID().uuidString)" + + private func makeDefaults() throws -> UserDefaults { + try #require(UserDefaults(suiteName: suiteName)) + } + + @Test("loadLastExportFormatId returns nil when nothing stored") + func formatIdNilWhenEmpty() throws { + let defaults = try makeDefaults() + defer { defaults.removePersistentDomain(forName: suiteName) } + + let storage = ExportDialogStorage(userDefaults: defaults) + #expect(storage.loadLastExportFormatId() == nil) + } + + @Test("saveLastExportFormatId round-trips") + func formatIdRoundTrip() throws { + let defaults = try makeDefaults() + defer { defaults.removePersistentDomain(forName: suiteName) } + + let storage = ExportDialogStorage(userDefaults: defaults) + storage.saveLastExportFormatId("sql") + + #expect(storage.loadLastExportFormatId() == "sql") + } + + @Test("saveLastExportFormatId overwrites previous value") + func formatIdOverwrites() throws { + let defaults = try makeDefaults() + defer { defaults.removePersistentDomain(forName: suiteName) } + + let storage = ExportDialogStorage(userDefaults: defaults) + storage.saveLastExportFormatId("csv") + storage.saveLastExportFormatId("xlsx") + + #expect(storage.loadLastExportFormatId() == "xlsx") + } + + @Test("loadLastImportEncoding returns utf8 when nothing stored") + func encodingDefaultsToUTF8() throws { + let defaults = try makeDefaults() + defer { defaults.removePersistentDomain(forName: suiteName) } + + let storage = ExportDialogStorage(userDefaults: defaults) + #expect(storage.loadLastImportEncoding() == .utf8) + } + + @Test("saveLastImportEncoding round-trips all cases", arguments: ImportEncoding.allCases) + func encodingRoundTrip(encoding: ImportEncoding) throws { + let defaults = try makeDefaults() + defer { defaults.removePersistentDomain(forName: suiteName) } + + let storage = ExportDialogStorage(userDefaults: defaults) + storage.saveLastImportEncoding(encoding) + + #expect(storage.loadLastImportEncoding() == encoding) + } + + @Test("loadLastImportEncoding falls back to utf8 for unknown stored value") + func encodingFallsBackOnUnknownValue() throws { + let defaults = try makeDefaults() + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set("Shift-JIS", forKey: "com.TablePro.import.dialog.lastEncoding") + let storage = ExportDialogStorage(userDefaults: defaults) + + #expect(storage.loadLastImportEncoding() == .utf8) + } +} diff --git a/docs/features/import-export.mdx b/docs/features/import-export.mdx index 0c7cf3076..e4c761eb9 100644 --- a/docs/features/import-export.mdx +++ b/docs/features/import-export.mdx @@ -14,6 +14,8 @@ Export data in five formats (CSV, JSON, SQL, MQL, XLSX), import SQL files with g 3. Choose a format, select tables, configure options 4. Click **Export** +TablePro remembers the last format and options you exported with, so the dialog opens ready for a repeat export. Option changes stick only after a successful export; cancelling the dialog discards them. **Reset to Defaults** under the options restores the stock settings for the current format. + **MongoDB**: SQL export is not available. Use CSV, JSON, MQL, or XLSX. MQL generates `db.collection.insertMany([...])` scripts for `mongosh`. @@ -211,7 +213,7 @@ Import `.sql` and `.sql.gz` files (statements execute directly against your data Click **File** > **Import** > **From SQL** (`Cmd+Shift+I`), or drag and drop a `.sql` / `.sql.gz` file onto the app. - Set encoding, transaction wrapping, and foreign key check options. + Set encoding, transaction wrapping, and foreign key check options. TablePro remembers the encoding and options from your last successful import; **Reset to Defaults** next to the Options header restores them. Review the SQL preview, statement count, and file size. Click **Import** to execute. From 0b1d83f7932cae1debd34af06c1b6b9f212f18b0 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 5 Jun 2026 23:54:06 +0700 Subject: [PATCH 2/2] refactor(plugins): extract plugin settings snapshot and rename transfer dialog storage --- .../Core/Plugins/PluginSettingsSnapshot.swift | 30 ++++++ ...rage.swift => TransferDialogStorage.swift} | 6 +- TablePro/Views/Export/ExportDialog.swift | 35 +++---- TablePro/Views/Import/ImportDialog.swift | 35 +++---- .../Plugins/PluginSettingsSnapshotTests.swift | 93 +++++++++++++++++++ ...swift => TransferDialogStorageTests.swift} | 18 ++-- 6 files changed, 163 insertions(+), 54 deletions(-) create mode 100644 TablePro/Core/Plugins/PluginSettingsSnapshot.swift rename TablePro/Core/Storage/{ExportDialogStorage.swift => TransferDialogStorage.swift} (89%) create mode 100644 TableProTests/Core/Plugins/PluginSettingsSnapshotTests.swift rename TableProTests/Core/Storage/{ExportDialogStorageTests.swift => TransferDialogStorageTests.swift} (82%) diff --git a/TablePro/Core/Plugins/PluginSettingsSnapshot.swift b/TablePro/Core/Plugins/PluginSettingsSnapshot.swift new file mode 100644 index 000000000..a228bcd1e --- /dev/null +++ b/TablePro/Core/Plugins/PluginSettingsSnapshot.swift @@ -0,0 +1,30 @@ +// +// PluginSettingsSnapshot.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +struct PluginSettingsSnapshot { + private var entries: [(plugin: any SettablePluginDiscoverable, data: Data)] + + init(plugins: [any SettablePluginDiscoverable]) { + entries = plugins.compactMap { plugin in + guard let data = plugin.snapshotSettingsData() else { return nil } + return (plugin, data) + } + } + + func restore() { + for entry in entries { + entry.plugin.restoreSettingsData(entry.data) + } + } + + mutating func recapture(_ plugin: any SettablePluginDiscoverable) { + guard let index = entries.firstIndex(where: { $0.plugin === plugin }), + let data = plugin.snapshotSettingsData() else { return } + entries[index].data = data + } +} diff --git a/TablePro/Core/Storage/ExportDialogStorage.swift b/TablePro/Core/Storage/TransferDialogStorage.swift similarity index 89% rename from TablePro/Core/Storage/ExportDialogStorage.swift rename to TablePro/Core/Storage/TransferDialogStorage.swift index f5fd2fa2d..386e87194 100644 --- a/TablePro/Core/Storage/ExportDialogStorage.swift +++ b/TablePro/Core/Storage/TransferDialogStorage.swift @@ -1,12 +1,12 @@ // -// ExportDialogStorage.swift +// TransferDialogStorage.swift // TablePro // import Foundation -final class ExportDialogStorage { - static let shared = ExportDialogStorage() +final class TransferDialogStorage { + static let shared = TransferDialogStorage() private let defaults: UserDefaults diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index 4c9e23fd7..192a1cd11 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -26,7 +26,7 @@ struct ExportDialog: View { @State private var showProgressDialog = false @State private var showSuccessDialog = false @State private var exportedFileURL: URL? - @State private var settingsSnapshots: [String: Data] = [:] + @State private var settingsSnapshot: PluginSettingsSnapshot? @State private var exportSucceeded = false // MARK: - User Preferences @@ -97,18 +97,18 @@ struct ExportDialog: View { .background(Color(nsColor: .windowBackgroundColor)) .onAppear { let available = availableFormats - if let lastFormatId = ExportDialogStorage.shared.loadLastExportFormatId(), + if let lastFormatId = TransferDialogStorage.shared.loadLastExportFormatId(), available.contains(where: { type(of: $0).formatId == lastFormatId }) { config.formatId = lastFormatId } else if !available.contains(where: { type(of: $0).formatId == config.formatId }), let first = available.first { config.formatId = type(of: first).formatId } - captureSettingsSnapshots() + captureSettingsSnapshot() } .onDisappear { if !exportSucceeded { - restoreSettingsSnapshots() + restoreSettingsSnapshot() } } .onChange(of: config.formatId) { @@ -536,34 +536,27 @@ struct ExportDialog: View { // MARK: - Actions - private func captureSettingsSnapshots() { - var snapshots: [String: Data] = [:] - for plugin in availableFormats { - guard let settable = plugin as? any SettablePluginDiscoverable, - let data = settable.snapshotSettingsData() else { continue } - snapshots[type(of: plugin).formatId] = data - } - settingsSnapshots = snapshots + private func captureSettingsSnapshot() { + settingsSnapshot = PluginSettingsSnapshot( + plugins: availableFormats.compactMap { $0 as? any SettablePluginDiscoverable } + ) } - private func restoreSettingsSnapshots() { - for (formatId, data) in settingsSnapshots { - let plugin = PluginManager.shared.exportPlugin(forFormat: formatId) - (plugin as? any SettablePluginDiscoverable)?.restoreSettingsData(data) - } - settingsSnapshots.removeAll() + private func restoreSettingsSnapshot() { + settingsSnapshot?.restore() + settingsSnapshot = nil } private func resetCurrentFormatSettings() { guard let settable = currentPlugin as? any SettablePluginDiscoverable else { return } settable.resetSettingsToDefaults() - settingsSnapshots[config.formatId] = settable.snapshotSettingsData() + settingsSnapshot?.recapture(settable) } private func recordSuccessfulExport() { exportSucceeded = true - ExportDialogStorage.shared.saveLastExportFormatId(config.formatId) - settingsSnapshots.removeAll() + TransferDialogStorage.shared.saveLastExportFormatId(config.formatId) + settingsSnapshot = nil } /// Instantly populate the current database from sidebar tables (no network). diff --git a/TablePro/Views/Import/ImportDialog.swift b/TablePro/Views/Import/ImportDialog.swift index 9af86b4c2..a919a7758 100644 --- a/TablePro/Views/Import/ImportDialog.swift +++ b/TablePro/Views/Import/ImportDialog.swift @@ -23,7 +23,7 @@ struct ImportDialog: View { self.connection = connection self.initialFileURL = initialFileURL self._selectedFormatId = State(initialValue: initialFormatId) - self._selectedEncoding = State(initialValue: ExportDialogStorage.shared.loadLastImportEncoding()) + self._selectedEncoding = State(initialValue: TransferDialogStorage.shared.loadLastImportEncoding()) } // MARK: - State @@ -35,7 +35,7 @@ struct ImportDialog: View { @State private var isCountingStatements = false @State private var selectedEncoding: ImportEncoding @State private var selectedFormatId: String = "sql" - @State private var settingsSnapshots: [String: Data] = [:] + @State private var settingsSnapshot: PluginSettingsSnapshot? @State private var importSucceeded = false @State private var showProgressDialog = false @State private var showSuccessDialog = false @@ -86,7 +86,7 @@ struct ImportDialog: View { selectedFormatId = type(of: first).formatId } } - captureSettingsSnapshots() + captureSettingsSnapshot() } .onExitCommand { if !(importService?.state.isImporting ?? false) { @@ -104,7 +104,7 @@ struct ImportDialog: View { importTask?.cancel() cleanupTempFiles() if !importSucceeded { - restoreSettingsSnapshots() + restoreSettingsSnapshot() } } .sheet(isPresented: $showProgressDialog) { @@ -315,35 +315,28 @@ struct ImportDialog: View { // MARK: - Actions - private func captureSettingsSnapshots() { - var snapshots: [String: Data] = [:] - for plugin in availableFormats { - guard let settable = plugin as? any SettablePluginDiscoverable, - let data = settable.snapshotSettingsData() else { continue } - snapshots[type(of: plugin).formatId] = data - } - settingsSnapshots = snapshots + private func captureSettingsSnapshot() { + settingsSnapshot = PluginSettingsSnapshot( + plugins: availableFormats.compactMap { $0 as? any SettablePluginDiscoverable } + ) } - private func restoreSettingsSnapshots() { - for (formatId, data) in settingsSnapshots { - let plugin = PluginManager.shared.importPlugin(forFormat: formatId) - (plugin as? any SettablePluginDiscoverable)?.restoreSettingsData(data) - } - settingsSnapshots.removeAll() + private func restoreSettingsSnapshot() { + settingsSnapshot?.restore() + settingsSnapshot = nil } private func resetOptionsToDefaults() { selectedEncoding = .utf8 guard let settable = currentPlugin as? any SettablePluginDiscoverable else { return } settable.resetSettingsToDefaults() - settingsSnapshots[selectedFormatId] = settable.snapshotSettingsData() + settingsSnapshot?.recapture(settable) } private func recordSuccessfulImport() { importSucceeded = true - ExportDialogStorage.shared.saveLastImportEncoding(selectedEncoding) - settingsSnapshots.removeAll() + TransferDialogStorage.shared.saveLastImportEncoding(selectedEncoding) + settingsSnapshot = nil } @MainActor diff --git a/TableProTests/Core/Plugins/PluginSettingsSnapshotTests.swift b/TableProTests/Core/Plugins/PluginSettingsSnapshotTests.swift new file mode 100644 index 000000000..389ce75a1 --- /dev/null +++ b/TableProTests/Core/Plugins/PluginSettingsSnapshotTests.swift @@ -0,0 +1,93 @@ +// +// PluginSettingsSnapshotTests.swift +// TableProTests +// + +import Foundation +import SwiftUI +import TableProPluginKit +import Testing +@testable import TablePro + +@Suite("PluginSettingsSnapshot", .serialized) +struct PluginSettingsSnapshotTests { + private struct TestOptions: Codable, Equatable { + var flag = false + var count = 0 + } + + private final class TestPlugin: SettablePlugin { + static let settingsStorageId = "test.snapshot.holder" + + var settings = TestOptions() { + didSet { saveSettings() } + } + + func resetSettingsToDefaults() { + settings = TestOptions() + } + } + + private final class BareDiscoverablePlugin: SettablePluginDiscoverable { + func settingsView() -> AnyView? { nil } + } + + private func cleanup() { + PluginSettingsStorage(pluginId: TestPlugin.settingsStorageId).removeAll() + } + + @Test("restore reverts captured plugins") + func restoreReverts() { + defer { cleanup() } + let plugin = TestPlugin() + plugin.settings = TestOptions(flag: true, count: 1) + let snapshot = PluginSettingsSnapshot(plugins: [plugin]) + + plugin.settings = TestOptions(flag: false, count: 9) + snapshot.restore() + + #expect(plugin.settings == TestOptions(flag: true, count: 1)) + } + + @Test("recapture updates the rollback baseline") + func recaptureUpdatesBaseline() { + defer { cleanup() } + let plugin = TestPlugin() + plugin.settings = TestOptions(flag: true, count: 1) + var snapshot = PluginSettingsSnapshot(plugins: [plugin]) + + plugin.resetSettingsToDefaults() + snapshot.recapture(plugin) + plugin.settings = TestOptions(flag: true, count: 5) + snapshot.restore() + + #expect(plugin.settings == TestOptions()) + } + + @Test("plugins without snapshot data are skipped") + func skipsPluginsWithoutData() { + defer { cleanup() } + let bare = BareDiscoverablePlugin() + let plugin = TestPlugin() + plugin.settings = TestOptions(flag: true, count: 2) + let snapshot = PluginSettingsSnapshot(plugins: [bare, plugin]) + + plugin.settings = TestOptions(flag: false, count: 0) + snapshot.restore() + + #expect(plugin.settings == TestOptions(flag: true, count: 2)) + } + + @Test("recapture ignores plugins that were not captured") + func recaptureIgnoresUncaptured() { + defer { cleanup() } + let plugin = TestPlugin() + plugin.settings = TestOptions(flag: true, count: 3) + var snapshot = PluginSettingsSnapshot(plugins: []) + + snapshot.recapture(plugin) + snapshot.restore() + + #expect(plugin.settings == TestOptions(flag: true, count: 3)) + } +} diff --git a/TableProTests/Core/Storage/ExportDialogStorageTests.swift b/TableProTests/Core/Storage/TransferDialogStorageTests.swift similarity index 82% rename from TableProTests/Core/Storage/ExportDialogStorageTests.swift rename to TableProTests/Core/Storage/TransferDialogStorageTests.swift index 148eac721..2fa0152a5 100644 --- a/TableProTests/Core/Storage/ExportDialogStorageTests.swift +++ b/TableProTests/Core/Storage/TransferDialogStorageTests.swift @@ -1,5 +1,5 @@ // -// ExportDialogStorageTests.swift +// TransferDialogStorageTests.swift // TableProTests // @@ -7,8 +7,8 @@ import Foundation import Testing @testable import TablePro -@Suite("ExportDialogStorage") -struct ExportDialogStorageTests { +@Suite("TransferDialogStorage") +struct TransferDialogStorageTests { private let suiteName = "com.TablePro.tests.exportDialog.\(UUID().uuidString)" private func makeDefaults() throws -> UserDefaults { @@ -20,7 +20,7 @@ struct ExportDialogStorageTests { let defaults = try makeDefaults() defer { defaults.removePersistentDomain(forName: suiteName) } - let storage = ExportDialogStorage(userDefaults: defaults) + let storage = TransferDialogStorage(userDefaults: defaults) #expect(storage.loadLastExportFormatId() == nil) } @@ -29,7 +29,7 @@ struct ExportDialogStorageTests { let defaults = try makeDefaults() defer { defaults.removePersistentDomain(forName: suiteName) } - let storage = ExportDialogStorage(userDefaults: defaults) + let storage = TransferDialogStorage(userDefaults: defaults) storage.saveLastExportFormatId("sql") #expect(storage.loadLastExportFormatId() == "sql") @@ -40,7 +40,7 @@ struct ExportDialogStorageTests { let defaults = try makeDefaults() defer { defaults.removePersistentDomain(forName: suiteName) } - let storage = ExportDialogStorage(userDefaults: defaults) + let storage = TransferDialogStorage(userDefaults: defaults) storage.saveLastExportFormatId("csv") storage.saveLastExportFormatId("xlsx") @@ -52,7 +52,7 @@ struct ExportDialogStorageTests { let defaults = try makeDefaults() defer { defaults.removePersistentDomain(forName: suiteName) } - let storage = ExportDialogStorage(userDefaults: defaults) + let storage = TransferDialogStorage(userDefaults: defaults) #expect(storage.loadLastImportEncoding() == .utf8) } @@ -61,7 +61,7 @@ struct ExportDialogStorageTests { let defaults = try makeDefaults() defer { defaults.removePersistentDomain(forName: suiteName) } - let storage = ExportDialogStorage(userDefaults: defaults) + let storage = TransferDialogStorage(userDefaults: defaults) storage.saveLastImportEncoding(encoding) #expect(storage.loadLastImportEncoding() == encoding) @@ -73,7 +73,7 @@ struct ExportDialogStorageTests { defer { defaults.removePersistentDomain(forName: suiteName) } defaults.set("Shift-JIS", forKey: "com.TablePro.import.dialog.lastEncoding") - let storage = ExportDialogStorage(userDefaults: defaults) + let storage = TransferDialogStorage(userDefaults: defaults) #expect(storage.loadLastImportEncoding() == .utf8) }