diff --git a/apple/Plugins/FormatPluginTests/MQLExportReadsRowsTests.swift b/apple/Plugins/FormatPluginTests/MQLExportReadsRowsTests.swift new file mode 100644 index 0000000000..f1337ac4d0 --- /dev/null +++ b/apple/Plugins/FormatPluginTests/MQLExportReadsRowsTests.swift @@ -0,0 +1,17 @@ +// +// MQLExportReadsRowsTests.swift +// FormatPluginTests +// + +import TableProPluginKit +import Testing + +struct MQLExportReadsRowsTests { + @Test("An MQL export reads a collection's documents only with Data checked") + func readsRowsOnlyWithData() { + let plugin = MQLExportPlugin() + #expect(plugin.readsRows(optionValues: [true, true, true], kind: .table)) + #expect(!plugin.readsRows(optionValues: [true, true, false], kind: .table)) + #expect(plugin.readsRows(optionValues: [], kind: .table)) + } +} diff --git a/apple/Plugins/MQLExportPlugin/MQLExportPlugin.swift b/apple/Plugins/MQLExportPlugin/MQLExportPlugin.swift index 6074a27517..5577c1dee8 100644 --- a/apple/Plugins/MQLExportPlugin/MQLExportPlugin.swift +++ b/apple/Plugins/MQLExportPlugin/MQLExportPlugin.swift @@ -19,9 +19,10 @@ final class MQLExportPlugin: ObservableObject, ExportFormatPlugin, SettablePlugi static let supportedDatabaseTypeIds = ["MongoDB"] static let perTableOptionColumns: [PluginExportOptionColumn] = [ + // The DROP statement, left untranslated: the catalog's "Drop" is the verb that deletes a table. PluginExportOptionColumn(id: "drop", label: "Drop", width: 44), - PluginExportOptionColumn(id: "indexes", label: "Indexes", width: 44), - PluginExportOptionColumn(id: "data", label: "Data", width: 44) + PluginExportOptionColumn(id: "indexes", label: String(localized: "Indexes", bundle: .main), width: 44), + PluginExportOptionColumn(id: "data", label: String(localized: "Data", bundle: .main), width: 44) ] typealias Settings = MQLExportOptions @@ -41,6 +42,11 @@ final class MQLExportPlugin: ObservableObject, ExportFormatPlugin, SettablePlugi optionValues.contains(true) } + // Without it, Data off still counts every collection's rows and the progress sits at 0 of N. + func readsRows(optionValues: [Bool], kind: PluginExportObjectKind) -> Bool { + optionValues.count <= 2 || optionValues[2] + } + @MainActor func settingsView() -> AnyView? { AnyView(MQLExportOptionsView(plugin: self)) diff --git a/apple/Plugins/SQLExportPlugin/SQLExportPlugin.swift b/apple/Plugins/SQLExportPlugin/SQLExportPlugin.swift index 832efd4521..a7bcae450a 100644 --- a/apple/Plugins/SQLExportPlugin/SQLExportPlugin.swift +++ b/apple/Plugins/SQLExportPlugin/SQLExportPlugin.swift @@ -20,9 +20,10 @@ final class SQLExportPlugin: ObservableObject, ExportFormatPlugin, SettablePlugi static let excludedDatabaseTypeIds = ["MongoDB", "Redis"] static let perTableOptionColumns: [PluginExportOptionColumn] = [ - PluginExportOptionColumn(id: "structure", label: "Structure", width: 56), + PluginExportOptionColumn(id: "structure", label: String(localized: "Structure", bundle: .main), width: 56), + // The DROP statement, left untranslated: the catalog's "Drop" is the verb that deletes a table. PluginExportOptionColumn(id: "drop", label: "Drop", width: 44), - PluginExportOptionColumn(id: "data", label: "Data", width: 44) + PluginExportOptionColumn(id: "data", label: String(localized: "Data", bundle: .main), width: 44) ] static let supportedObjectKinds: [PluginExportObjectKind] = [ diff --git a/apple/TablePro/Models/Export/ExportModels.swift b/apple/TablePro/Models/Export/ExportModels.swift index 0f6c42f8ee..288bdb1e30 100644 --- a/apple/TablePro/Models/Export/ExportModels.swift +++ b/apple/TablePro/Models/Export/ExportModels.swift @@ -292,6 +292,8 @@ struct ExportDatabaseItem: Identifiable { /// Set when the container's own checkbox was turned on before its unread kinds arrived, so /// they arrive selected like everything else the click reached. var selectsLoadedObjects = false + // Column values set before the unread kinds arrived, by column index; they arrive with them. + var arrivingOptionValues: [Int: Bool] = [:] init( id: UUID = UUID(), @@ -313,8 +315,12 @@ struct ExportDatabaseItem: Identifiable { /// An object that arrives already selected (a restored row) stays so. Any other is selected /// only when the container was ticked whole and nothing in it has been unticked since. - func mergingLoadedObjects(_ loaded: [ExportObjectItem]) -> ExportDatabaseItem { - let selectsArrivals = selectsLoadedObjects && objects.allSatisfy(\.isSelected) + func mergingLoadedObjects( + _ loaded: [ExportObjectItem], + writes: (ExportObjectItem) -> Bool = { _ in true } + ) -> ExportDatabaseItem { + // An object that writes nothing stays out of a container tick, so it does not count as unticked. + let selectsArrivals = selectsLoadedObjects && objects.filter(writes).allSatisfy(\.isSelected) let incoming = loaded.filter { unloadedKinds.contains($0.kind) } // The table listing reported these sequences as tables; one relation, so the sequence replaces it. let sequenceNames = Set(incoming.filter { $0.kind == .sequence }.map(\.name)) @@ -331,9 +337,32 @@ struct ExportDatabaseItem: Identifiable { merged.unloadedKinds = [] merged.isLoadingObjects = false merged.selectsLoadedObjects = false + merged.arrivingOptionValues = [:] return merged } + // Kept values go on after normalizing, which replaces an arrival's empty list with the defaults. + func mergingLoadedObjects(_ loaded: [ExportObjectItem], layout: ExportOptionLayout) -> ExportDatabaseItem? { + let merged = mergingLoadedObjects(loaded, writes: layout.writesSomething) + guard !merged.objects.isEmpty, let normalized = [merged].normalized(for: layout).first else { return nil } + let arrivals = Set(normalized.objects.map(\.id)).subtracting(objects.map(\.id)) + return normalized.applyingOptionValues(arrivingOptionValues, toObjects: arrivals, layout: layout) + } + + func applyingOptionValues( + _ values: [Int: Bool], + toObjects ids: Set, + layout: ExportOptionLayout + ) -> ExportDatabaseItem { + guard !values.isEmpty, !ids.isEmpty else { return self } + var updated = self + updated.objects = objects.map { object in + guard ids.contains(object.id) else { return object } + return object.settingOptions(values.filter { layout.supports(columnAt: $0.key, kind: object.kind) }) + } + return updated + } + func withID(_ id: UUID) -> ExportDatabaseItem { var copy = ExportDatabaseItem( id: id, @@ -344,6 +373,7 @@ struct ExportDatabaseItem: Identifiable { ) copy.isLoadingObjects = isLoadingObjects copy.selectsLoadedObjects = selectsLoadedObjects + copy.arrivingOptionValues = arrivingOptionValues return copy } @@ -377,7 +407,75 @@ struct ExportDatabaseItem: Identifiable { } } +enum ExportKindReconciliation: Equatable { + case unchanged + // The kinds left after narrowing, so a wider format later reads again. + case narrowed(loadedKinds: Set) + case reload + + static func next( + loadedKinds: Set, + wanted: Set + ) -> ExportKindReconciliation { + guard !loadedKinds.isEmpty else { return .unchanged } + guard wanted.isSubset(of: loadedKinds) else { return .reload } + return wanted == loadedKinds ? .unchanged : .narrowed(loadedKinds: wanted) + } +} + +// Resolved by the dialog so the tree never looks a plugin up itself. +struct ExportOptionLayout { + let columns: [PluginExportOptionColumn] + let defaultValues: [Bool] + // False for a structure-only format, which ignores a row scope. + let readsRows: Bool + let supports: (String, PluginExportObjectKind) -> Bool + + static var none: ExportOptionLayout { + ExportOptionLayout(columns: [], defaultValues: [], readsRows: true) { _, _ in true } + } + + func supports(columnAt index: Int, kind: PluginExportObjectKind) -> Bool { + columns.indices.contains(index) && supports(columns[index].id, kind) + } + + func writesSomething(_ object: ExportObjectItem) -> Bool { + columns.isEmpty || object.optionValues.contains(true) + } + + // Unread kinds count with the value they will arrive with. + func values( + ofColumn index: Int, + in objects: [ExportObjectItem], + unloadedKinds: Set = [], + arriving: [Int: Bool] = [:] + ) -> [Bool] { + guard columns.indices.contains(index) else { return [] } + let fallback = defaultValues.count == columns.count ? defaultValues[index] : columns[index].defaultValue + var values = objects + .filter { supports(columnAt: index, kind: $0.kind) } + .map { $0.optionValues[safe: index] ?? columns[index].defaultValue } + if unloadedKinds.contains(where: { supports(columnAt: index, kind: $0) }) { + values.append(arriving[index] ?? fallback) + } + return values + } +} + extension ExportObjectItem { + // Never selects. A selected object left with nothing on is deselected: it would export nothing. + func settingOptions(_ values: [Int: Bool]) -> ExportObjectItem { + guard values.keys.contains(where: optionValues.indices.contains) else { return self } + var updated = self + for (index, value) in values where updated.optionValues.indices.contains(index) { + updated.optionValues[index] = value + } + if !updated.optionValues.contains(true) { + updated.isSelected = false + } + return updated + } + func normalized(forOptionColumnCount optionColumnCount: Int, defaultOptionValues: [Bool]) -> ExportObjectItem { guard optionColumnCount > 0 else { return self } let fallback = defaultOptionValues.count == optionColumnCount @@ -451,6 +549,23 @@ extension [ExportDatabaseItem] { } } + // Masking keeps a routine from carrying a Data flag that would count it as exportable. + func normalized(for layout: ExportOptionLayout) -> [ExportDatabaseItem] { + normalizingOptionValues(optionColumnCount: layout.columns.count, defaultOptionValues: layout.defaultValues) + .maskingUnsupportedOptions(columns: layout.columns, supports: layout.supports) + } + + // A profile's option values belong to its format, so the tree is reset for that format first. + func applyingProfile( + _ profile: ExportProfile, + layout: ExportOptionLayout, + databaseType: DatabaseType + ) -> [ExportDatabaseItem] { + ExportProfileStorage.apply(profile, to: self) + .clearingRowScopes(unavailableOn: databaseType) + .normalized(for: layout) + } + func normalizingOptionValues(optionColumnCount: Int, defaultOptionValues: [Bool]) -> [ExportDatabaseItem] { map { database in var normalizedDatabase = database @@ -464,6 +579,7 @@ extension [ExportDatabaseItem] { func resettingOptionValues(to values: [Bool]) -> [ExportDatabaseItem] { map { database in var resetDatabase = database + resetDatabase.arrivingOptionValues = [:] resetDatabase.objects = database.objects.map { object in var resetObject = object resetObject.optionValues = values @@ -486,6 +602,24 @@ extension [ExportDatabaseItem] { } } + func settingOption( + _ index: Int, + to value: Bool, + objectIDs ids: Set, + arrivingIn databaseID: UUID? = nil + ) -> [ExportDatabaseItem] { + map { database in + var updated = database + updated.objects = database.objects.map { object in + ids.contains(object.id) ? object.settingOptions([index: value]) : object + } + if database.id == databaseID { + updated.arrivingOptionValues[index] = value + } + return updated + } + } + func clearingRowScopes(unavailableOn databaseType: DatabaseType) -> [ExportDatabaseItem] { map { database in var clearedDatabase = database diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index adfb8a9735..acb83f2277 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -57710,6 +57710,8 @@ } }, "Data" : { + "extractionState" : "manual", + "generatesSymbol" : false, "localizations" : { "fr" : { "stringUnit" : { @@ -93672,6 +93674,8 @@ } }, "Indexes" : { + "extractionState" : "manual", + "generatesSymbol" : false, "localizations" : { "fr" : { "stringUnit" : { @@ -173973,6 +173977,8 @@ } }, "Structure" : { + "extractionState" : "manual", + "generatesSymbol" : false, "localizations" : { "fr" : { "stringUnit" : { diff --git a/apple/TablePro/Views/Backup/BackupScopeModel.swift b/apple/TablePro/Views/Backup/BackupScopeModel.swift index 70a1915b26..f7688e3f4f 100644 --- a/apple/TablePro/Views/Backup/BackupScopeModel.swift +++ b/apple/TablePro/Views/Backup/BackupScopeModel.swift @@ -115,33 +115,89 @@ final class BackupScopeModel: ObservableObject { rows = Self.togglingObject(id, in: database, rows: rows) } + enum Row: Equatable { + case database(String) + case object(String, database: String) + } + + func toggle(_ selection: [Row]) { + rows = Self.toggling(selection, in: rows) + } + + // One direction for every row, and an object under a selected database is left to it: toggling both flipped it back. + static func toggling(_ selection: [Row], in rows: [DatabaseRow]) -> [DatabaseRow] { + let databases = Set(selection.compactMap { row -> String? in + guard case .database(let name) = row else { return nil } + return name + }) + let targets = selection.filter { row in + guard case .object(_, let database) = row else { return true } + return !databases.contains(database) + } + let turningOn = targets.contains { target in + switch target { + case .database(let name): + return rows.first { $0.name == name }.map { !$0.isSelected || $0.isMixed } ?? false + case .object(let id, let database): + return rows.first { $0.name == database }?.objects.first { $0.id == id }.map { !$0.isSelected } ?? false + } + } + return targets.reduce(rows) { rows, target in + switch target { + case .database(let name): + return settingDatabase(name, selected: turningOn, in: rows) + case .object(let id, let database): + return settingObject(id, in: database, selected: turningOn, rows: rows) + } + } + } + /// Ticking a mixed database means "all of it", which is the state a user reaches by unticking /// a few tables and then changing their mind. static func togglingDatabase( _ database: String, in rows: [DatabaseRow] + ) -> [DatabaseRow] { + guard let row = rows.first(where: { $0.name == database }) else { return rows } + return settingDatabase(database, selected: !row.isSelected || row.isMixed, in: rows) + } + + static func settingDatabase( + _ database: String, + selected: Bool, + in rows: [DatabaseRow] ) -> [DatabaseRow] { var rows = rows guard let index = rows.firstIndex(where: { $0.name == database }) else { return rows } - let turningOn = !rows[index].isSelected || rows[index].isMixed - rows[index].isSelected = turningOn + rows[index].isSelected = selected for objectIndex in rows[index].objects.indices { - rows[index].objects[objectIndex].isSelected = turningOn + rows[index].objects[objectIndex].isSelected = selected } return rows } + static func togglingObject( + _ id: String, + in database: String, + rows: [DatabaseRow] + ) -> [DatabaseRow] { + guard let object = rows.first(where: { $0.name == database })?.objects.first(where: { $0.id == id }) + else { return rows } + return settingObject(id, in: database, selected: !object.isSelected, rows: rows) + } + /// Unticking the last object is a decision to leave the database out, not a decision to back it /// up empty. Ticking one again brings the database back carrying only that object. - static func togglingObject( + static func settingObject( _ id: String, in database: String, + selected: Bool, rows: [DatabaseRow] ) -> [DatabaseRow] { var rows = rows guard let index = rows.firstIndex(where: { $0.name == database }) else { return rows } guard let objectIndex = rows[index].objects.firstIndex(where: { $0.id == id }) else { return rows } - rows[index].objects[objectIndex].isSelected.toggle() + rows[index].objects[objectIndex].isSelected = selected rows[index].isSelected = rows[index].objects.contains(where: \.isSelected) return rows } diff --git a/apple/TablePro/Views/Backup/BackupScopeTreeView.swift b/apple/TablePro/Views/Backup/BackupScopeTreeView.swift index aee2d5036e..9625427c75 100644 --- a/apple/TablePro/Views/Backup/BackupScopeTreeView.swift +++ b/apple/TablePro/Views/Backup/BackupScopeTreeView.swift @@ -25,6 +25,8 @@ internal struct BackupScopeTreeView: NSViewRepresentable { let outlineView = CheckboxOutlineView() outlineView.headerView = nil outlineView.style = .plain + // The default style left the column 14 pt wider than the clip view, so a swipe slid the rows sideways. + outlineView.columnAutoresizingStyle = .firstColumnOnlyAutoresizingStyle outlineView.rowSizeStyle = .custom outlineView.rowHeight = 24 outlineView.allowsMultipleSelection = true @@ -335,16 +337,14 @@ internal final class BackupScopeTreeCoordinator: NSObject, NSOutlineViewDataSour internal func toggleSelectedRows() { guard let outlineView else { return } - for index in outlineView.selectedRowIndexes { - guard let node = outlineView.item(atRow: index) as? BackupScopeNode else { continue } + let selection = outlineView.selectedRowIndexes.compactMap { index -> BackupScopeModel.Row? in + guard let node = outlineView.item(atRow: index) as? BackupScopeNode else { return nil } switch node.kind { - case .database(let name): - model.toggleDatabase(name) - case .object(let database, let id): - model.toggleObject(id, in: database) - case .placeholder: - continue + case .database(let name): return .database(name) + case .object(let database, let id): return .object(id, database: database) + case .placeholder: return nil } } + model.toggle(selection) } } diff --git a/apple/TablePro/Views/Components/CheckboxOutlineView.swift b/apple/TablePro/Views/Components/CheckboxOutlineView.swift index 2fea752bed..62c298fe58 100644 --- a/apple/TablePro/Views/Components/CheckboxOutlineView.swift +++ b/apple/TablePro/Views/Components/CheckboxOutlineView.swift @@ -53,3 +53,47 @@ internal final class HostingTableCellView: NSTableCellView { hosting.rootView = content } } + +internal final class CheckboxTableCellView: NSTableCellView { + private let checkbox = NSButton(checkboxWithTitle: "", target: nil, action: nil) + private var modelState: NSControl.StateValue = .off + private var action: (() -> Void)? + + internal init(identifier: NSUserInterfaceItemIdentifier) { + super.init(frame: .zero) + self.identifier = identifier + checkbox.target = self + checkbox.action = #selector(clicked(_:)) + checkbox.translatesAutoresizingMaskIntoConstraints = false + addSubview(checkbox) + NSLayoutConstraint.activate([ + checkbox.centerXAnchor.constraint(equalTo: centerXAnchor), + checkbox.centerYAnchor.constraint(equalTo: centerYAnchor) + ]) + } + + @available(*, unavailable) + internal required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + internal func configure( + state: NSControl.StateValue, + isEnabled: Bool, + accessibilityLabel: String, + action: @escaping () -> Void + ) { + modelState = state + self.action = action + checkbox.allowsMixedState = state == .mixed + checkbox.state = state + checkbox.isEnabled = isEnabled + checkbox.setAccessibilityLabel(accessibilityLabel) + } + + // AppKit already moved the box to its next state; the action decides the real one. + @objc private func clicked(_ sender: NSButton) { + sender.state = modelState + action?() + } +} diff --git a/apple/TablePro/Views/Editor/SQLEditorCoordinator.swift b/apple/TablePro/Views/Editor/SQLEditorCoordinator.swift index 6a5697d164..547d6b41c5 100644 --- a/apple/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/apple/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -71,6 +71,11 @@ final class SQLEditorCoordinator: ObservableObject, TextViewCoordinator, TextVie } func scheduleEditorFocusClaim() { + // Query tabs share one editor, which installs only once, so a later tab's claim is taken here. + if hasInstalledEditorServices, !isDestroyed, let textView = controller?.textView, let window = textView.window { + window.makeFirstResponder(textView) + return + } focusClaimPending = true } diff --git a/apple/TablePro/Views/Export/ExportDialog.swift b/apple/TablePro/Views/Export/ExportDialog.swift index be3031f153..b23a834de8 100644 --- a/apple/TablePro/Views/Export/ExportDialog.swift +++ b/apple/TablePro/Views/Export/ExportDialog.swift @@ -30,6 +30,7 @@ struct ExportDialog: View { /// Which object kinds the last load actually read, so a format switch knows whether the tree it /// already holds can answer for the new format without another round trip. @State private var loadedObjectKinds: Set = [] + @State private var formatReconciliation: Task? /// Bumped each time a reload replaces the tree, so a container read started against the old /// tree cannot land in the new one. @@ -159,10 +160,6 @@ struct ExportDialog: View { restoreSettingsSnapshot() } } - .onChange(of: config.formatId) { _ in - resetOptionValues() - Task { await reconcileObjectKindsForFormat() } - } .onExitCommand { if !isExporting { isPresented = false @@ -238,7 +235,7 @@ struct ExportDialog: View { get: { config.formatId }, set: { newValue in guard let format = formatsToOffer.first(where: { Self.offerTag(for: $0) == newValue }) else { - config.formatId = newValue + selectFormat(newValue) return } Task { await installOfferedFormat(format) } @@ -254,16 +251,30 @@ struct ExportDialog: View { if let settable = plugin as? any SettablePluginDiscoverable { settingsSnapshot?.include(settable) } - config.formatId = format.formatId + selectFormat(format.formatId) } - private var currentOptionColumnCount: Int { - guard let plugin = currentPlugin else { return 0 } - return type(of: plugin).perTableOptionColumns.count + // One path for every format change, so a profile can wait for the reset and reload instead of racing it. + @discardableResult + private func selectFormat(_ formatId: String) -> Task? { + guard formatId != config.formatId else { return formatReconciliation } + config.formatId = formatId + resetOptionValues() + let reconciliation = Task { await reconcileObjectKindsForFormat() } + formatReconciliation = reconciliation + return reconciliation } - private var currentDefaultOptionValues: [Bool] { - currentPlugin?.defaultTableOptionValues() ?? [] + private var optionLayout: ExportOptionLayout { + guard let plugin = currentPlugin else { return .none } + let pluginType = type(of: plugin) + return ExportOptionLayout( + columns: pluginType.perTableOptionColumns, + defaultValues: plugin.defaultTableOptionValues(), + readsRows: !ExportFormatCatalog.writesStructureOnly(plugin) + ) { columnId, kind in + pluginType.supportsOption(columnId: columnId, for: kind) + } } // MARK: - Layout Constants @@ -293,15 +304,6 @@ struct ExportDialog: View { profileMenu Spacer() - - if let plugin = currentPlugin { - ForEach(type(of: plugin).perTableOptionColumns) { column in - Text(column.label) - .font(.subheadline.weight(.medium)) - .foregroundStyle(.secondary) - .frame(width: column.width, alignment: .center) - } - } } .padding(.horizontal, 12) .padding(.vertical, 10) @@ -336,6 +338,7 @@ struct ExportDialog: View { ExportObjectTreeView( databaseItems: $databaseItems, formatId: config.formatId, + options: optionLayout, databaseType: databaseType, loadColumns: { await columnNames(for: $0) }, onNeedsObjects: { databaseID in @@ -418,18 +421,19 @@ struct ExportDialog: View { private func applyProfile(_ profile: ExportProfile) async { profileApplication &+= 1 let application = profileApplication + let reconcile = availableFormatIds.contains(profile.formatId) + ? selectFormat(profile.formatId) + : formatReconciliation + let formatId = config.formatId + await reconcile?.value let named = Set(profile.entries.map(\.container)) for item in databaseItems where item.hasUnloadedObjects && named.contains(item.name) { + guard application == profileApplication else { return } await loadUnloadedObjects(databaseID: item.id) } - // A profile picked while this one waited for its reads is the user's latest choice. - guard application == profileApplication else { return } - if availableFormatIds.contains(profile.formatId) { - config.formatId = profile.formatId - } - databaseItems = normalizedForCurrentFormat( - ExportProfileStorage.apply(profile, to: databaseItems) - .clearingRowScopes(unavailableOn: databaseType)) + // A profile or a format picked while this one waited for its reads is the user's latest choice. + guard application == profileApplication, formatId == config.formatId else { return } + databaseItems = databaseItems.applyingProfile(profile, layout: optionLayout, databaseType: databaseType) } private func saveProfile() { @@ -653,33 +657,24 @@ struct ExportDialog: View { @MainActor private func reconcileObjectKindsForFormat() async { guard case .tables(let connection, _) = mode else { return } - let wanted = supportedObjectKinds - guard !loadedObjectKinds.isEmpty else { return } - guard wanted.isSubset(of: loadedObjectKinds) else { - await loadDatabaseItems(of: connection) + switch ExportKindReconciliation.next(loadedKinds: loadedObjectKinds, wanted: supportedObjectKinds) { + case .unchanged: return + case .narrowed(let kinds): + databaseItems = databaseItems.narrowed(to: kinds) + loadedObjectKinds = kinds + case .reload: + await loadDatabaseItems(of: connection) } - databaseItems = databaseItems.narrowed(to: wanted) } private func resetOptionValues() { databaseItems = normalizedForCurrentFormat( - databaseItems.resettingOptionValues(to: currentDefaultOptionValues)) + databaseItems.resettingOptionValues(to: optionLayout.defaultValues)) } - /// Aligns every row's option values with the chosen format's columns and clears the ones the - /// row's kind does not support, so a routine never carries a `Data` flag that would count it as - /// exportable for a phase it has no rows for. private func normalizedForCurrentFormat(_ items: [ExportDatabaseItem]) -> [ExportDatabaseItem] { - let normalized = items.normalizingOptionValues( - optionColumnCount: currentOptionColumnCount, - defaultOptionValues: currentDefaultOptionValues - ) - guard let plugin = currentPlugin else { return normalized } - let pluginType = type(of: plugin) - return normalized.maskingUnsupportedOptions(columns: pluginType.perTableOptionColumns) { columnId, kind in - pluginType.supportsOption(columnId: columnId, for: kind) - } + items.normalized(for: optionLayout) } // MARK: - Actions @@ -769,13 +764,18 @@ struct ExportDialog: View { @MainActor private func loadDatabaseItems(of connection: DatabaseConnection) async { let priorRows = ExportTreeBuilder.snapshots(of: databaseItems) + let kinds = supportedObjectKinds do { let items = try await treeBuilder(for: connection).build(priorRows: priorRows) - loadedObjectKinds = supportedObjectKinds + loadedObjectKinds = kinds treeGeneration += 1 databaseItems = normalizedForCurrentFormat(items.adoptingIdentities(of: databaseItems)) isLoading = false applyDefaultFileName(for: connection) + // The format changed while this read ran, and its reconcile had no tree to work on yet. + if kinds != supportedObjectKinds { + await reconcileObjectKindsForFormat() + } } catch is CancellationError { isLoading = false } catch { @@ -845,12 +845,11 @@ struct ExportDialog: View { guard generation == treeGeneration, let index = databaseItems.firstIndex(where: { $0.id == databaseID }) else { return } - let merged = databaseItems[index].mergingLoadedObjects(loaded) - guard !merged.objects.isEmpty, let normalized = normalizedForCurrentFormat([merged]).first else { + guard let merged = databaseItems[index].mergingLoadedObjects(loaded, layout: optionLayout) else { databaseItems.remove(at: index) return } - databaseItems[index] = normalized + databaseItems[index] = merged } private func applyDefaultFileName(for connection: DatabaseConnection) { diff --git a/apple/TablePro/Views/Export/ExportObjectRows.swift b/apple/TablePro/Views/Export/ExportObjectRows.swift index 05c1658dc4..bef475a196 100644 --- a/apple/TablePro/Views/Export/ExportObjectRows.swift +++ b/apple/TablePro/Views/Export/ExportObjectRows.swift @@ -95,16 +95,11 @@ internal struct ExportTreeContainerRow: View { } } -/// One object row. The option toggles stay positionally aligned with the format's full column list -/// for every kind, so a column this kind does not support leaves an empty slot of the same width -/// rather than shifting the ones after it. +// The checkbox only includes or excludes the object; what it writes is in the option columns. internal struct ExportTreeObjectRow: View { internal let object: ExportObjectItem internal let offersRowScope: Bool - internal let optionColumns: [PluginExportOptionColumn] - internal let supportsOption: (String, PluginExportObjectKind) -> Bool - internal let setSelected: (Bool) -> Void - internal let setOption: (Int, Bool) -> Void + internal let toggle: () -> Void internal let setRowScope: (PluginExportRowScope) -> Void internal let loadColumns: () async -> [String] @@ -114,21 +109,10 @@ internal struct ExportTreeObjectRow: View { internal var body: some View { HStack(spacing: 4) { - if optionColumns.isEmpty { - Toggle(object.name, isOn: Binding(get: { object.isSelected }, set: setSelected)) - .toggleStyle(.checkbox) - .labelsHidden() - .frame(width: 18) - } else { - TristateCheckbox( - state: checkboxState, - accessibilityLabel: object.name, - accessibilityValue: ExportObjectKindPresentation.checkboxValue(for: checkboxState) - ) { - setSelected(checkboxState != .checked) - } + Toggle(object.name, isOn: Binding(get: { object.isSelected }, set: { _ in toggle() })) + .toggleStyle(.checkbox) + .labelsHidden() .frame(width: 18) - } Image(systemName: ExportObjectKindPresentation.iconName(for: object.kind)) .foregroundStyle(ExportObjectKindPresentation.iconColor(for: object.kind)) @@ -153,12 +137,6 @@ internal struct ExportTreeObjectRow: View { if offersRowScope { rowScopeButton } - - if !optionColumns.isEmpty { - ForEach(Array(optionColumns.enumerated()), id: \.element.id) { index, column in - optionToggle(index: index, column: column) - } - } } } @@ -195,31 +173,4 @@ internal struct ExportTreeObjectRow: View { } } } - - @ViewBuilder - private func optionToggle(index: Int, column: PluginExportOptionColumn) -> some View { - if supportsOption(column.id, object.kind) { - Toggle(column.label, isOn: Binding( - get: { object.optionValues[safe: index] ?? column.defaultValue }, - set: { setOption(index, $0) } - )) - .toggleStyle(.checkbox) - .labelsHidden() - .disabled(!object.isSelected) - .frame(width: column.width, alignment: .center) - } else { - Color.clear.frame(width: column.width, height: 1) - } - } - - private var checkboxState: TristateCheckbox.State { - guard object.isSelected else { return .unchecked } - let supported = optionColumns.indices.filter { - supportsOption(optionColumns[$0].id, object.kind) - } - guard !supported.isEmpty else { return .checked } - let onCount = supported.count { object.optionValues[safe: $0] == true } - if onCount == 0 { return .unchecked } - return onCount == supported.count ? .checked : .mixed - } } diff --git a/apple/TablePro/Views/Export/ExportObjectTreeView.swift b/apple/TablePro/Views/Export/ExportObjectTreeView.swift index 31ac4afeaf..a2129390d7 100644 --- a/apple/TablePro/Views/Export/ExportObjectTreeView.swift +++ b/apple/TablePro/Views/Export/ExportObjectTreeView.swift @@ -14,9 +14,13 @@ import TableProPluginKit /// bindings are re-driven during an animated outline diff. That is the same Apple bug the /// connection sidebar's database tree hit, and the same answer: `NSOutlineView`, which also gives /// genuinely lazy children for a database holding thousands of objects. +/// +/// Option columns are real table columns, so each heading sits over its checkboxes whatever the +/// scroller style. internal struct ExportObjectTreeView: NSViewRepresentable { @Binding internal var databaseItems: [ExportDatabaseItem] internal let formatId: String + internal let options: ExportOptionLayout internal let databaseType: DatabaseType /// Reads one object's column names for the row-scope popover. Supplied by the dialog, which is @@ -34,6 +38,9 @@ internal struct ExportObjectTreeView: NSViewRepresentable { let outlineView = CheckboxOutlineView() outlineView.headerView = nil outlineView.style = .plain + outlineView.columnAutoresizingStyle = .firstColumnOnlyAutoresizingStyle + outlineView.intercellSpacing = NSSize(width: 4, height: 0) + outlineView.allowsColumnReordering = false outlineView.rowSizeStyle = .custom outlineView.rowHeight = 24 outlineView.allowsMultipleSelection = true @@ -48,7 +55,8 @@ internal struct ExportObjectTreeView: NSViewRepresentable { } outlineView.setAccessibilityLabel(String(localized: "Objects to export")) - let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("ExportObjectColumn")) + let column = NSTableColumn(identifier: ExportObjectTreeCoordinator.nameColumn) + column.title = String(localized: "Name") column.resizingMask = .autoresizingMask outlineView.addTableColumn(column) outlineView.outlineTableColumn = column @@ -71,8 +79,14 @@ internal struct ExportObjectTreeView: NSViewRepresentable { } } +fileprivate extension NSUserInterfaceItemIdentifier { + static let exportOptionCell = NSUserInterfaceItemIdentifier("ExportOptionCell") +} + @MainActor internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSource, NSOutlineViewDelegate { + internal static let nameColumn = NSUserInterfaceItemIdentifier("ExportObjectColumn") + internal var owner: ExportObjectTreeView private weak var outlineView: NSOutlineView? @@ -105,6 +119,9 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou let formatChanged = formatId != self.formatId self.databases = databases self.formatId = formatId + if formatChanged { + syncOptionColumns() + } guard fingerprint != shapeFingerprint else { guard formatChanged else { @@ -135,7 +152,7 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou guard let identities = pendingReloadIdentities else { let rows = IndexSet(integersIn: 0 ..< outlineView.numberOfRows) guard !rows.isEmpty else { return } - outlineView.reloadData(forRowIndexes: rows, columnIndexes: IndexSet(integer: 0)) + outlineView.reloadData(forRowIndexes: rows, columnIndexes: allColumns(of: outlineView)) return } pendingReloadIdentities = nil @@ -147,7 +164,41 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou rows.insert(row) } guard !rows.isEmpty else { return } - outlineView.reloadData(forRowIndexes: rows, columnIndexes: IndexSet(integer: 0)) + outlineView.reloadData(forRowIndexes: rows, columnIndexes: allColumns(of: outlineView)) + } + + private func allColumns(of outlineView: NSOutlineView) -> IndexSet { + IndexSet(integersIn: 0 ..< outlineView.numberOfColumns) + } + + // As wide as the plugin asks or the translated heading needs. + private func syncOptionColumns() { + guard let outlineView else { return } + for column in outlineView.tableColumns where column.identifier != Self.nameColumn { + outlineView.removeTableColumn(column) + } + let options = owner.options.columns + outlineView.headerView = options.isEmpty ? nil : NSTableHeaderView() + for (index, option) in options.enumerated() { + let column = NSTableColumn( + identifier: NSUserInterfaceItemIdentifier("ExportOptionColumn.\(index).\(option.id)") + ) + column.title = option.label + column.headerCell.alignment = NSTextAlignment.center + let width = max(option.width, ceil(column.headerCell.cellSize.width) + 8) + column.width = width + column.minWidth = width + column.maxWidth = width + column.resizingMask = [] + outlineView.addTableColumn(column) + } + } + + private func optionIndex(of tableColumn: NSTableColumn?) -> Int? { + guard let tableColumn else { return nil } + let parts = tableColumn.identifier.rawValue.split(separator: ".") + guard parts.count >= 2, parts[0] == "ExportOptionColumn", let index = Int(parts[1]) else { return nil } + return owner.options.columns.indices.contains(index) ? index : nil } /// What the builder opened, read once per container when the tree first meets it, so a reload @@ -238,22 +289,34 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou return false } - /// Space toggles every selected row, which is what the checkbox in each of them does on a - /// click. A container row toggles everything beneath it, exactly as clicking its checkbox does. + // One direction for every row: on unless every target is already checked. internal func toggleSelectedRows() { guard let outlineView else { return } - let nodes = outlineView.selectedRowIndexes.compactMap { + let selected = outlineView.selectedRowIndexes.compactMap { outlineView.item(atRow: $0) as? ExportOutlineNode } - guard !nodes.isEmpty else { return } - for node in nodes { + // The rows a click could change, minus any row under another of them: toggling both flipped it back. + let actionable = selected.filter { node in + if case .pending = node.kind { return false } + return selectionIsEnabled(for: node) + } + let identities = Set(actionable.map(\.identity)) + let targets = actionable.filter { node in + var parent = outlineView.parent(forItem: node) as? ExportOutlineNode + while let ancestor = parent { + if identities.contains(ancestor.identity) { return false } + parent = outlineView.parent(forItem: ancestor) as? ExportOutlineNode + } + return true + } + guard !targets.isEmpty else { return } + let turningOn = targets.contains { selectionState(for: $0) != .checked } + for node in targets { switch node.kind { case .database, .group: - toggleContainer(node) + setContainer(node, selected: turningOn) case .object(let databaseID, let objectID): - let isSelected = databases.first(where: { $0.id == databaseID })? - .objects.first(where: { $0.id == objectID })?.isSelected ?? false - setSelection(!isSelected, databaseID: databaseID, objectID: objectID) + setSelection(turningOn, databaseID: databaseID, objectID: objectID) case .pending: continue } @@ -279,6 +342,9 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou item: Any ) -> NSView? { guard let node = item as? ExportOutlineNode else { return nil } + if let index = optionIndex(of: tableColumn) { + return optionCell(column: index, node: node, in: outlineView) + } let identifier = NSUserInterfaceItemIdentifier("ExportObjectRow") let cell = outlineView.makeView(withIdentifier: identifier, owner: self) as? HostingTableCellView ?? HostingTableCellView(identifier: identifier) @@ -308,6 +374,7 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou state: containerState(for: node), toggle: { [weak self] in self?.toggleContainer(node) } ) + .disabled(!selectionIsEnabled(for: node)) ) case .group(_, let objectKind): return AnyView( @@ -318,6 +385,7 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou state: containerState(for: node), toggle: { [weak self] in self?.toggleContainer(node) } ) + .disabled(!selectionIsEnabled(for: node)) ) case .object(let databaseID, let objectID): guard let databaseIndex = databases.firstIndex(where: { $0.id == databaseID }), @@ -329,14 +397,9 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou return AnyView( ExportTreeObjectRow( object: object, - offersRowScope: object.offersRowScope(on: owner.databaseType) && formatReadsRows, - optionColumns: optionColumns, - supportsOption: supportsOption, - setSelected: { [weak self] isSelected in - self?.setSelection(isSelected, databaseID: databaseID, objectID: objectID) - }, - setOption: { [weak self] index, value in - self?.setOption(index, to: value, databaseID: databaseID, objectID: objectID) + offersRowScope: object.offersRowScope(on: owner.databaseType) && owner.options.readsRows, + toggle: { [weak self] in + self?.toggleObject(databaseID: databaseID, objectID: objectID) }, setRowScope: { [weak self] scope in self?.setRowScope(scope, databaseID: databaseID, objectID: objectID) @@ -360,30 +423,6 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou // MARK: - Selection - private var plugin: (any ExportFormatPlugin)? { - PluginManager.shared.exportPlugin(forFormat: formatId) - } - - // A format that writes structure alone ignores a row scope, so it offers none. - private var formatReadsRows: Bool { - plugin.map { !ExportFormatCatalog.writesStructureOnly($0) } ?? true - } - - private var optionColumns: [PluginExportOptionColumn] { - guard let plugin else { return [] } - return type(of: plugin).perTableOptionColumns - } - - private var defaultOptionValues: [Bool] { - plugin?.defaultTableOptionValues() ?? [] - } - - private var supportsOption: (String, PluginExportObjectKind) -> Bool { - guard let plugin else { return { _, _ in true } } - let pluginType = type(of: plugin) - return { columnId, kind in pluginType.supportsOption(columnId: columnId, for: kind) } - } - private func objects(under node: ExportOutlineNode) -> [ExportObjectItem] { switch node.kind { case .database(let databaseID): @@ -408,8 +447,21 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou return database } - private func containerState(for node: ExportOutlineNode) -> TristateCheckbox.State { + // An object a column checkbox turned all off stays out of a tick, or click order would decide the dump. + private func selectableObjects(under node: ExportOutlineNode) -> [ExportObjectItem] { + objects(under: node).filter(owner.options.writesSomething) + } + + // A container where every loaded object writes nothing and no unread kind can arrive: a tick would + // select nothing. An object row stays enabled, since ticking it restores the format's defaults. + internal func selectionIsEnabled(for node: ExportOutlineNode) -> Bool { + if case .object = node.kind { return true } let items = objects(under: node) + return items.isEmpty || unloadedDatabase(at: node) != nil || items.contains(where: owner.options.writesSomething) + } + + internal func containerState(for node: ExportOutlineNode) -> TristateCheckbox.State { + let items = selectableObjects(under: node) guard !items.isEmpty else { return unloadedDatabase(at: node)?.selectsLoadedObjects == true ? .checked : .unchecked } @@ -418,14 +470,24 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou return selected == items.count ? .checked : .mixed } + private func selectionState(for node: ExportOutlineNode) -> TristateCheckbox.State { + guard case .object(let databaseID, let objectID) = node.kind else { return containerState(for: node) } + return object(databaseID: databaseID, objectID: objectID)?.isSelected == true ? .checked : .unchecked + } + + private func object(databaseID: UUID, objectID: UUID) -> ExportObjectItem? { + databases.first(where: { $0.id == databaseID })?.objects.first(where: { $0.id == objectID }) + } + + private func toggleContainer(_ node: ExportOutlineNode) { + setContainer(node, selected: containerState(for: node) != .checked) + } + /// Ticking a container whose kinds are not all read yet also asks for them, and they arrive /// selected unless something in it is unticked first. - private func toggleContainer(_ node: ExportOutlineNode) { - let items = objects(under: node) + private func setContainer(_ node: ExportOutlineNode, selected turningOn: Bool) { + let items = turningOn ? selectableObjects(under: node) : objects(under: node) let unloaded = unloadedDatabase(at: node) - let turningOn = items.isEmpty - ? !(unloaded?.selectsLoadedObjects ?? true) - : items.contains { !$0.isSelected } let ids = Set(items.map(\.id)) var reloading: Set = [] if let unloaded { reloading.insert("db:\(unloaded.id.uuidString)") } @@ -447,6 +509,95 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou normalizeOptions(for: ids) } + internal func toggleObject(databaseID: UUID, objectID: UUID) { + guard let object = object(databaseID: databaseID, objectID: objectID) else { return } + setSelection(!object.isSelected, databaseID: databaseID, objectID: objectID) + } + + // MARK: - Option columns + + private func optionCell(column index: Int, node: ExportOutlineNode, in outlineView: NSOutlineView) -> NSView? { + let label = owner.options.columns[index].label + let state: NSControl.StateValue + let isEnabled: Bool + let title: String + let action: () -> Void + switch node.kind { + case .database, .group: + guard let containerState = optionState(ofColumn: index, under: node) else { return nil } + state = containerState + isEnabled = true + title = containerTitle(of: node) + action = { [weak self] in self?.toggleOption(index, under: node) } + case .object(let databaseID, let objectID): + guard let object = object(databaseID: databaseID, objectID: objectID), + owner.options.supports(columnAt: index, kind: object.kind) + else { return nil } + let isOn = object.optionValues[safe: index] ?? owner.options.columns[index].defaultValue + state = isOn ? .on : .off + isEnabled = object.isSelected + title = object.name + action = { [weak self] in + self?.setOption(index, to: !isOn, databaseID: databaseID, objectID: objectID) + } + case .pending: + return nil + } + let cell = outlineView.makeView(withIdentifier: .exportOptionCell, owner: self) as? CheckboxTableCellView + ?? CheckboxTableCellView(identifier: .exportOptionCell) + cell.configure( + state: state, + isEnabled: isEnabled, + accessibilityLabel: String(format: String(localized: "%1$@ for %2$@"), label, title), + action: action + ) + return cell + } + + private func containerTitle(of node: ExportOutlineNode) -> String { + switch node.kind { + case .database(let databaseID): + return databases.first(where: { $0.id == databaseID })?.name ?? "" + case .group(_, let objectKind): + return ExportObjectKindPresentation.groupTitle(for: objectKind) + case .object, .pending: + return "" + } + } + + private func optionValues(ofColumn index: Int, under node: ExportOutlineNode) -> [Bool] { + let unloaded = unloadedDatabase(at: node) + return owner.options.values( + ofColumn: index, + in: objects(under: node), + unloadedKinds: unloaded?.unloadedKinds ?? [], + arriving: unloaded?.arrivingOptionValues ?? [:] + ) + } + + internal func optionState(ofColumn index: Int, under node: ExportOutlineNode) -> NSControl.StateValue? { + let values = optionValues(ofColumn: index, under: node) + guard !values.isEmpty else { return nil } + if values.allSatisfy({ $0 }) { return .on } + return values.contains(true) ? .mixed : .off + } + + // Never selects: on a container with nothing ticked it sets what each object carries once ticked. + internal func toggleOption(_ index: Int, under node: ExportOutlineNode) { + guard let state = optionState(ofColumn: index, under: node) else { return } + let turningOn = state != .on + let options = owner.options + let ids = Set(objects(under: node).filter { options.supports(columnAt: index, kind: $0.kind) }.map(\.id)) + let unloaded = unloadedDatabase(at: node).flatMap { database in + database.unloadedKinds.contains { options.supports(columnAt: index, kind: $0) } ? database : nil + } + var reloading: Set = [] + if let unloaded { reloading.insert("db:\(unloaded.id.uuidString)") } + mutateDatabases(touching: ids, reloading: reloading) { databases in + databases = databases.settingOption(index, to: turningOn, objectIDs: ids, arrivingIn: unloaded?.id) + } + } + private func setSelection(_ isSelected: Bool, databaseID: UUID, objectID: UUID) { mutateDatabases(touching: [objectID]) { databases in guard let databaseIndex = databases.firstIndex(where: { $0.id == databaseID }), @@ -461,10 +612,10 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou /// A row selected with every one of its options off would be counted as selected and export /// nothing, so selecting it restores the format's defaults for the kinds that support them. private func normalizeOptions(for ids: Set) { - let columns = optionColumns + let columns = owner.options.columns guard !columns.isEmpty else { return } - let defaults = defaultOptionValues - let supports = supportsOption + let defaults = owner.options.defaultValues + let supports = owner.options.supports mutateDatabases(touching: ids) { databases in for databaseIndex in databases.indices { for objectIndex in databases[databaseIndex].objects.indices @@ -483,13 +634,7 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou private func setOption(_ index: Int, to value: Bool, databaseID: UUID, objectID: UUID) { mutateDatabases(touching: [objectID]) { databases in - guard let databaseIndex = databases.firstIndex(where: { $0.id == databaseID }), - let objectIndex = databases[databaseIndex].objects.firstIndex(where: { $0.id == objectID }), - databases[databaseIndex].objects[objectIndex].optionValues.indices.contains(index) - else { return } - databases[databaseIndex].objects[objectIndex].optionValues[index] = value - databases[databaseIndex].objects[objectIndex].isSelected = - databases[databaseIndex].objects[objectIndex].optionValues.contains(true) + databases = databases.settingOption(index, to: value, objectIDs: [objectID]) } } diff --git a/apple/TablePro/Views/Export/ExportTreeBuilder.swift b/apple/TablePro/Views/Export/ExportTreeBuilder.swift index 00d3cf425e..eeaf5bfc78 100644 --- a/apple/TablePro/Views/Export/ExportTreeBuilder.swift +++ b/apple/TablePro/Views/Export/ExportTreeBuilder.swift @@ -150,6 +150,7 @@ struct ExportRowSnapshot { let kind: PluginExportObjectKind let isSelected: Bool let optionValues: [Bool] + var rowScope: PluginExportRowScope = .unrestricted } /// Builds the export dialog's container tree. @@ -191,7 +192,8 @@ struct ExportTreeBuilder { container: database.name, kind: object.kind, isSelected: object.isSelected, - optionValues: object.optionValues + optionValues: object.optionValues, + rowScope: object.rowScope ) } } @@ -443,7 +445,8 @@ struct ExportTreeBuilder { inContainer: containerRef, isCurrentContainer: isCurrentContainer ), - optionValues: priorRow?.optionValues ?? [] + optionValues: priorRow?.optionValues ?? [], + rowScope: priorRow?.rowScope ?? .unrestricted ) } } diff --git a/apple/TableProTests/Models/ExportOptionColumnTests.swift b/apple/TableProTests/Models/ExportOptionColumnTests.swift new file mode 100644 index 0000000000..e9fc462552 --- /dev/null +++ b/apple/TableProTests/Models/ExportOptionColumnTests.swift @@ -0,0 +1,219 @@ +// +// ExportOptionColumnTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +extension ExportOptionLayout { + static var sqlForTests: ExportOptionLayout { + ExportOptionLayout( + columns: [ + PluginExportOptionColumn(id: "structure", label: "Structure", width: 56), + PluginExportOptionColumn(id: "drop", label: "Drop", width: 44), + PluginExportOptionColumn(id: "data", label: "Data", width: 44) + ], + defaultValues: [true, true, true], + readsRows: true + ) { columnId, kind in + switch columnId { + case "data": return kind.carriesRows + case "drop": return kind != .grant + default: return true + } + } + } +} + +struct ExportOptionColumnTests { + private let layout = ExportOptionLayout.sqlForTests + + private func table(_ name: String, selected: Bool = true, _ values: [Bool] = [true, true, true]) -> ExportObjectItem { + ExportObjectItem(name: name, kind: .table, isSelected: selected, optionValues: values) + } + + @Test("A column's values come from the objects whose kind has it") + func valuesSkipUnsupportedKinds() { + let objects = [ + table("users", [true, true, false]), + ExportObjectItem(name: "recalc", kind: .routine, isSelected: true, optionValues: [true, true, false]) + ] + #expect(layout.values(ofColumn: 2, in: objects) == [false]) + #expect(layout.values(ofColumn: 1, in: objects) == [true, true]) + } + + @Test("No object with the column gives no values, so the container leaves the slot blank") + func noSupportingObjectGivesNoValues() { + let routines = [ExportObjectItem(name: "recalc", kind: .routine, optionValues: [true, true, false])] + #expect(layout.values(ofColumn: 2, in: routines).isEmpty) + } + + @Test("Unread kinds count with the value they will arrive with") + func unreadKindsCountWithTheirArrivingValue() { + let objects = [table("users", [true, false, true])] + #expect(layout.values(ofColumn: 1, in: objects, unloadedKinds: [.routine]) == [false, true]) + #expect(layout.values(ofColumn: 1, in: objects, unloadedKinds: [.routine], arriving: [1: false]) == [false, false]) + #expect(layout.values(ofColumn: 2, in: objects, unloadedKinds: [.routine]) == [true]) + } + + @Test("Turning a column off keeps the objects that still write something selected") + func columnOffKeepsSelection() { + let databases = [ExportDatabaseItem(name: "app", objects: [table("users"), table("orders")])] + let ids = Set(databases[0].objects.map(\.id)) + + let updated = databases.settingOption(2, to: false, objectIDs: ids) + + #expect(updated[0].objects.allSatisfy { $0.optionValues == [true, true, false] && $0.isSelected }) + } + + @Test("A selected object left with no option on is deselected") + func lastOptionOffDeselects() { + let databases = [ExportDatabaseItem(name: "app", objects: [table("users", [false, false, true])])] + + let updated = databases.settingOption(2, to: false, objectIDs: [databases[0].objects[0].id]) + + #expect(updated[0].objects[0].optionValues == [false, false, false]) + #expect(!updated[0].objects[0].isSelected) + } + + @Test("A column write never selects, and an unticked object keeps the value for when it is ticked") + func columnWriteNeverSelects() { + let databases = [ExportDatabaseItem(name: "app", objects: [table("users", selected: false)])] + + let updated = databases.settingOption(2, to: false, objectIDs: [databases[0].objects[0].id]) + + #expect(!updated[0].objects[0].isSelected) + #expect(updated[0].objects[0].optionValues == [true, true, false]) + } + + @Test("A column write reaches only the objects it names") + func columnWriteReachesOnlyNamedObjects() { + let databases = [ExportDatabaseItem(name: "app", objects: [table("users"), table("orders")])] + + let updated = databases.settingOption(2, to: false, objectIDs: [databases[0].objects[0].id]) + + #expect(updated[0].objects[1].optionValues == [true, true, true]) + } + + @Test("A container with unread kinds keeps the value for them, and only that container") + func arrivingValueIsKeptPerContainer() { + let databases = [ + ExportDatabaseItem(name: "public", objects: [table("users")]), + ExportDatabaseItem(name: "sales", objects: [table("orders")], unloadedKinds: [.routine]) + ] + + let updated = databases.settingOption(1, to: false, objectIDs: [], arrivingIn: databases[1].id) + + #expect(updated[0].arrivingOptionValues.isEmpty) + #expect(updated[1].arrivingOptionValues == [1: false]) + } + + @Test("Arrivals take the container's values over the format's defaults, for the columns their kind has") + func arrivalsTakeTheContainersValues() { + let routine = ExportObjectItem(name: "recalc", kind: .routine, isSelected: true, optionValues: [true, true, false]) + let existing = table("users") + let database = ExportDatabaseItem(name: "sales", objects: [existing, routine]) + + let updated = database.applyingOptionValues([1: false, 2: true], toObjects: [routine.id], layout: layout) + + #expect(updated.objects[0].optionValues == [true, true, true]) + #expect(updated.objects[1].optionValues == [true, false, false]) + #expect(updated.objects[1].isSelected) + } + + @Test("An arrival left with nothing to write arrives unselected") + func arrivalWithNothingOnIsDeselected() { + let routine = ExportObjectItem(name: "recalc", kind: .routine, isSelected: true, optionValues: [true, true, false]) + let database = ExportDatabaseItem(name: "sales", objects: [routine]) + + let updated = database.applyingOptionValues([0: false, 1: false], toObjects: [routine.id], layout: layout) + + #expect(!updated.objects[0].isSelected) + } + + @Test("The merge consumes the arriving values, a reload keeps them and a format change clears them") + func arrivingValuesLifecycle() { + var database = ExportDatabaseItem(name: "sales", objects: [table("orders")], unloadedKinds: [.routine]) + database.arrivingOptionValues = [1: false] + + #expect(database.withID(UUID()).arrivingOptionValues == [1: false]) + #expect(database.mergingLoadedObjects([]).arrivingOptionValues.isEmpty) + #expect([database].resettingOptionValues(to: [true, true, true])[0].arrivingOptionValues.isEmpty) + } + + @Test("Arrivals take the kept values after the format's defaults fill in their empty lists") + func mergeAppliesKeptValuesAfterNormalizing() throws { + let orders = table("orders") + var database = ExportDatabaseItem(name: "sales", objects: [orders], unloadedKinds: [.routine, .view]) + database.selectsLoadedObjects = true + database.arrivingOptionValues = [1: false] + let loaded = [ + ExportObjectItem(name: "recalc", kind: .routine), + ExportObjectItem(name: "active_orders", kind: .view) + ] + + let merged = try #require(database.mergingLoadedObjects(loaded, layout: layout)) + + #expect(merged.objects[0].optionValues == [true, true, true]) + #expect(merged.objects.dropFirst().allSatisfy { $0.optionValues == [true, false, false] && $0.isSelected }) + #expect(merged.arrivingOptionValues.isEmpty) + } + + @Test("A tick that skipped an object writing nothing still brings the unread kinds in selected") + func arrivalsSelectedPastAnAllOffObject() throws { + var database = ExportDatabaseItem( + name: "sales", + objects: [table("orders"), ExportObjectItem(name: "v", kind: .view, optionValues: [false, false, false])], + unloadedKinds: [.routine] + ) + database.selectsLoadedObjects = true + + let merged = try #require(database.mergingLoadedObjects([ExportObjectItem(name: "recalc", kind: .routine)], layout: layout)) + + #expect(merged.objects.first { $0.name == "recalc" }?.isSelected == true) + #expect(merged.objects.first { $0.name == "v" }?.isSelected == false) + } + + @Test("A read that finds nothing leaves no container") + func mergeOfNothingIsNil() { + let database = ExportDatabaseItem(name: "empty", objects: [], unloadedKinds: [.routine]) + #expect(database.mergingLoadedObjects([], layout: layout) == nil) + } + + @Test("Narrowing records the kinds that are left, so widening again reads them back") + func narrowThenWidenReloads() { + let sqlKinds: Set = [.table, .view, .routine, .trigger] + let csvKinds: Set = [.table, .view] + + let toCSV = ExportKindReconciliation.next(loadedKinds: sqlKinds, wanted: csvKinds) + #expect(toCSV == .narrowed(loadedKinds: csvKinds)) + guard case .narrowed(let left) = toCSV else { return } + #expect(ExportKindReconciliation.next(loadedKinds: left, wanted: sqlKinds) == .reload) + } + + @Test("Nothing loaded yet means the load in flight decides the kinds") + func reconcileBeforeTheFirstLoadDoesNothing() { + #expect(ExportKindReconciliation.next(loadedKinds: [], wanted: [.table]) == .unchanged) + #expect(ExportKindReconciliation.next(loadedKinds: [.table], wanted: [.table]) == .unchanged) + } + + @Test("A profile's options survive being applied over a tree reset for its format") + func profileKeepsItsOptions() { + let users = table("users", selected: false) + let profile = ExportProfile(name: "Schema only", formatId: "sql", entries: [ + ExportProfile.Entry( + container: "app", name: "users", kind: .table, + optionValues: [true, false, false], rowScope: .unrestricted + ) + ]) + let reset = [ExportDatabaseItem(name: "app", objects: [users])].resettingOptionValues(to: layout.defaultValues) + + let applied = reset.applyingProfile(profile, layout: layout, databaseType: .postgresql) + + #expect(applied[0].objects[0].isSelected) + #expect(applied[0].objects[0].optionValues == [true, false, false]) + } +} diff --git a/apple/TableProTests/ViewModels/BackupScopeModelTests.swift b/apple/TableProTests/ViewModels/BackupScopeModelTests.swift index 635db32703..8e345ee86e 100644 --- a/apple/TableProTests/ViewModels/BackupScopeModelTests.swift +++ b/apple/TableProTests/ViewModels/BackupScopeModelTests.swift @@ -106,6 +106,27 @@ struct BackupScopeModelTests { #expect(scopes[0].scope.objects.map(\.name) == ["carts"]) } + @Test("Space over a database and its objects unticks them once instead of flipping the objects back") + func spaceOverNestedRowsActsOnce() { + let rows = [row("sales", selected: true, objects: [("orders", true), ("carts", true)])] + let toggled = BackupScopeModel.toggling( + [.database("sales"), .object("orders", database: "sales"), .object("carts", database: "sales")], + in: rows + ) + #expect(!toggled[0].isSelected) + #expect(toggled[0].objects.allSatisfy { !$0.isSelected }) + } + + @Test("Space over rows in different states turns them all on") + func spaceOverMixedSelectionTurnsAllOn() { + let rows = [ + row("sales", selected: true, objects: [("orders", true)]), + row("billing", selected: false) + ] + let toggled = BackupScopeModel.toggling([.database("sales"), .database("billing")], in: rows) + #expect(toggled.allSatisfy { $0.isSelected }) + } + @Test("The footer counts files for a run and objects for a narrowed one") func summaryText() { let whole = [row("sales", selected: true)] diff --git a/apple/TableProTests/Views/Editor/SQLEditorFocusClaimTests.swift b/apple/TableProTests/Views/Editor/SQLEditorFocusClaimTests.swift new file mode 100644 index 0000000000..14e7011437 --- /dev/null +++ b/apple/TableProTests/Views/Editor/SQLEditorFocusClaimTests.swift @@ -0,0 +1,85 @@ +// Query tabs share one editor, so a new tab's focus claim reaches an editor that is already in the window. + +import AppKit +import SwiftUI +import TableProEditorKit +import TableProTestSupport +import Testing + +@testable import TablePro + +@MainActor +struct SQLEditorFocusClaimTests { + private final class Model { + var text = "SELECT 1;" + var cursorPositions: [CursorPosition] = [] + } + + private final class FocusableView: NSView { + override var acceptsFirstResponder: Bool { true } + } + + private func input(_ model: Model, tabID: UUID, claimFocus: Bool) -> SQLEditorInput { + var input = SQLEditorInput( + text: Binding(get: { model.text }, set: { model.text = $0 }), + cursorPositions: Binding(get: { model.cursorPositions }, set: { model.cursorPositions = $0 }), + databaseType: .sqlite, + tabID: tabID + ) + input.claimFocusOnAppear = claimFocus + return input + } + + private func mount(_ editor: SQLEditorController) -> (NSWindow, FocusableView) { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 600, height: 400), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + let content = NSView(frame: NSRect(x: 0, y: 0, width: 600, height: 400)) + let sidebar = FocusableView(frame: NSRect(x: 0, y: 0, width: 200, height: 400)) + let editorView = editor.textViewController.view + editorView.frame = NSRect(x: 200, y: 0, width: 400, height: 400) + content.addSubview(sidebar) + content.addSubview(editorView) + window.contentView = content + return (window, sidebar) + } + + private func showEditorWithFocusElsewhere(_ model: Model) async throws -> (SQLEditorController, NSWindow, NSView) { + let editor = SQLEditorController(input: input(model, tabID: UUID(), claimFocus: false)) + let (window, sidebar) = mount(editor) + let textView = try #require(editor.textViewController.textView) + try await waitUntil { window.firstResponder === textView } + _ = window.makeFirstResponder(sidebar) + try #require(window.firstResponder === sidebar) + return (editor, window, textView) + } + + @Test("A new tab that claims focus takes it from the view holding it, in an editor already shown") + func claimTakesFocusInAShownEditor() async throws { + let model = Model() + let (editor, window, textView) = try await showEditorWithFocusElsewhere(model) + defer { editor.teardown() } + defer { window.orderOut(nil) } + + editor.apply(input(model, tabID: UUID(), claimFocus: true)) + + #expect(window.firstResponder === textView) + } + + @Test("A tab shown without a claim leaves focus where it is") + func noClaimKeepsFocus() async throws { + let model = Model() + let (editor, window, _) = try await showEditorWithFocusElsewhere(model) + defer { editor.teardown() } + defer { window.orderOut(nil) } + let holder = window.firstResponder + + editor.apply(input(model, tabID: UUID(), claimFocus: false)) + + #expect(window.firstResponder === holder) + } +} diff --git a/apple/TableProTests/Views/Export/ExportObjectTreeCoordinatorTests.swift b/apple/TableProTests/Views/Export/ExportObjectTreeCoordinatorTests.swift index bf4219cfe5..5cbf4f0e35 100644 --- a/apple/TableProTests/Views/Export/ExportObjectTreeCoordinatorTests.swift +++ b/apple/TableProTests/Views/Export/ExportObjectTreeCoordinatorTests.swift @@ -20,7 +20,7 @@ struct ExportObjectTreeCoordinatorTests { let outlineView = NSOutlineView() var coordinator: ExportObjectTreeCoordinator? - init() { + init(options: ExportOptionLayout = .none) { let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("ExportObjectColumn")) outlineView.addTableColumn(column) outlineView.outlineTableColumn = column @@ -29,6 +29,7 @@ struct ExportObjectTreeCoordinatorTests { let view = ExportObjectTreeView( databaseItems: Binding(get: { self.items }, set: { self.items = $0 }), formatId: "sql", + options: options, databaseType: .postgresql, loadColumns: { _ in [] }, onNeedsObjects: { self.requests.append($0) } @@ -54,6 +55,11 @@ struct ExportObjectTreeCoordinatorTests { func node(of identity: String) -> ExportOutlineNode? { row(of: identity).flatMap { outlineView.item(atRow: $0) as? ExportOutlineNode } } + + func pressSpace(on node: ExportOutlineNode) { + outlineView.selectRowIndexes(IndexSet(integer: outlineView.row(forItem: node)), byExtendingSelection: false) + coordinator?.toggleSelectedRows() + } } private static func table(_ name: String, in schema: String) -> ExportObjectItem { @@ -149,4 +155,220 @@ struct ExportObjectTreeCoordinatorTests { #expect(selectable == false) } + + private static func sqlTree() -> ExportDatabaseItem { + ExportDatabaseItem( + name: "app", + objects: [ + ExportObjectItem(name: "users", kind: .table, isSelected: true, optionValues: [true, true, true]), + ExportObjectItem(name: "orders", kind: .table, isSelected: true, optionValues: [true, true, true]) + ], + isExpanded: true + ) + } + + @Test("A database's Data checkbox turns Data off for every table under it and keeps them selected") + func databaseOptionCheckboxSetsTheColumn() throws { + let harness = Harness(options: .sqlForTests) + let database = Self.sqlTree() + harness.apply([database]) + let node = try #require(harness.node(of: "db:\(database.id.uuidString)")) + + harness.coordinator?.toggleOption(2, under: node) + + let updated = try #require(harness.items.first) + #expect(updated.objects.allSatisfy { $0.optionValues == [true, true, false] && $0.isSelected }) + } + + @Test("A mixed column turns on for every object on the first click") + func mixedColumnTurnsOn() throws { + let harness = Harness(options: .sqlForTests) + var database = Self.sqlTree() + database.objects[0].optionValues = [true, false, true] + harness.apply([database]) + let node = try #require(harness.node(of: "db:\(database.id.uuidString)")) + + harness.coordinator?.toggleOption(1, under: node) + + let updated = try #require(harness.items.first) + #expect(updated.objects.allSatisfy { $0.optionValues == [true, true, true] }) + } + + @Test("A column set on a schema with unread kinds is kept for them") + func columnOnAPendingSchemaIsKeptForArrivals() throws { + let harness = Harness(options: .sqlForTests) + let (publicSchema, sales) = Self.realTree() + harness.apply([publicSchema, sales]) + let node = try #require(harness.node(of: "db:\(sales.id.uuidString)")) + + harness.coordinator?.toggleOption(1, under: node) + + let updated = try #require(harness.items.first { $0.id == sales.id }) + #expect(updated.arrivingOptionValues == [1: false]) + #expect(harness.requests.isEmpty) + } + + @Test("Space over a database and its tables unticks everything once instead of flipping it back") + func spaceOverNestedRowsActsOnce() throws { + let harness = Harness(options: .sqlForTests) + let database = Self.sqlTree() + harness.apply([database]) + harness.outlineView.selectRowIndexes(IndexSet(integersIn: 0 ..< harness.outlineView.numberOfRows), byExtendingSelection: false) + + harness.coordinator?.toggleSelectedRows() + + let updated = try #require(harness.items.first) + #expect(updated.objects.allSatisfy { !$0.isSelected }) + } + + @Test("Each option column gets a real table column with a heading, and a format without columns has none") + func optionColumnsAreTableColumns() { + let withColumns = Harness(options: .sqlForTests) + withColumns.apply([Self.sqlTree()]) + #expect(withColumns.outlineView.tableColumns.map(\.title).dropFirst() == ["Structure", "Drop", "Data"]) + #expect(withColumns.outlineView.headerView != nil) + + let without = Harness() + without.apply([Self.sqlTree()]) + #expect(without.outlineView.tableColumns.count == 1) + #expect(without.outlineView.headerView == nil) + } + + @Test("A group whose kind has no Data column leaves that cell empty") + func unsupportedColumnHasNoContainerState() throws { + let harness = Harness(options: .sqlForTests) + let database = ExportDatabaseItem(name: "app", objects: [ + ExportObjectItem(name: "users", kind: .table, isSelected: true, optionValues: [true, true, true]), + ExportObjectItem(name: "recalc", kind: .routine, isSelected: true, optionValues: [true, true, false]) + ]) + harness.apply([database]) + let routines = try #require(harness.node(of: "group:\(database.id.uuidString):\(PluginExportObjectKind.routine.rawValue)")) + + #expect(harness.coordinator?.optionState(ofColumn: 2, under: routines) == nil) + #expect(harness.coordinator?.optionState(ofColumn: 1, under: routines) == .on) + } + + @Test("Clicking an object's checkbox only includes or excludes it, whatever its options") + func objectCheckboxIsSelectionOnly() throws { + let harness = Harness(options: .sqlForTests) + var database = Self.sqlTree() + database.objects[0].optionValues = [true, false, false] + harness.apply([database]) + + harness.coordinator?.toggleObject(databaseID: database.id, objectID: database.objects[0].id) + + let updated = try #require(harness.items.first?.objects.first) + #expect(!updated.isSelected) + #expect(updated.optionValues == [true, false, false]) + } + + /// Without it, Structure and Drop off before the tick handed views and routines the format's + /// defaults back, and a data-only export wrote their DDL. + @Test("Turning columns off before or after ticking the database gives the same export") + func clickOrderDoesNotChangeTheExport() throws { + func tree() -> ExportDatabaseItem { + ExportDatabaseItem(name: "app", objects: [ + ExportObjectItem(name: "users", kind: .table, optionValues: [true, true, true]), + ExportObjectItem(name: "active_users", kind: .view, optionValues: [true, true, false]) + ]) + } + func export(_ steps: (Harness, ExportObjectTreeCoordinator, ExportOutlineNode) -> Void) throws -> [String] { + let harness = Harness(options: .sqlForTests) + let database = tree() + harness.apply([database]) + let node = try #require(harness.node(of: "db:\(database.id.uuidString)")) + let coordinator = try #require(harness.coordinator) + steps(harness, coordinator, node) + let objects = try #require(harness.items.first).objects + return objects.filter { $0.isSelected }.map { "\($0.name)=\($0.optionValues)" } + } + + let tickFirst = try export { harness, coordinator, node in + harness.pressSpace(on: node) + coordinator.toggleOption(0, under: node) + coordinator.toggleOption(1, under: node) + } + let tickLast = try export { harness, coordinator, node in + coordinator.toggleOption(0, under: node) + coordinator.toggleOption(1, under: node) + harness.pressSpace(on: node) + } + + #expect(tickFirst == ["users=[false, false, true]"]) + #expect(tickLast == tickFirst) + } + + @Test("A group whose every option is off cannot be ticked, so its objects stay out") + func allOffGroupStaysOut() throws { + let harness = Harness(options: .sqlForTests) + let database = ExportDatabaseItem(name: "app", objects: [ + ExportObjectItem(name: "users", kind: .table, isSelected: true, optionValues: [true, true, true]), + ExportObjectItem(name: "recalc", kind: .routine, optionValues: [true, true, false]) + ]) + harness.apply([database]) + let identity = "group:\(database.id.uuidString):\(PluginExportObjectKind.routine.rawValue)" + let routines = try #require(harness.node(of: identity)) + let coordinator = try #require(harness.coordinator) + coordinator.toggleOption(0, under: routines) + coordinator.toggleOption(1, under: routines) + + harness.pressSpace(on: try #require(harness.node(of: identity))) + + let routine = try #require(harness.items.first?.objects.first { $0.kind == .routine }) + #expect(!routine.isSelected) + #expect(!coordinator.selectionIsEnabled(for: routines)) + } + + @Test("A group that cannot be ticked does not keep Space from unticking the rows selected with it") + func spaceIgnoresAnAllOffGroup() throws { + let harness = Harness(options: .sqlForTests) + let database = ExportDatabaseItem(name: "app", objects: [ + ExportObjectItem(name: "users", kind: .table, isSelected: true, optionValues: [true, true, true]), + ExportObjectItem(name: "recalc", kind: .routine, optionValues: [false, false, false]) + ]) + harness.apply([database]) + let tables = try #require(harness.row(of: "group:\(database.id.uuidString):\(PluginExportObjectKind.table.rawValue)")) + let routines = try #require(harness.row(of: "group:\(database.id.uuidString):\(PluginExportObjectKind.routine.rawValue)")) + harness.outlineView.selectRowIndexes(IndexSet([tables, routines]), byExtendingSelection: false) + + harness.coordinator?.toggleSelectedRows() + + let users = try #require(harness.items.first?.objects.first { $0.name == "users" }) + #expect(!users.isSelected) + } + + @Test("Space ticks an object whose options are all off, as a click does, back to the defaults") + func spaceTicksAnAllOffObject() throws { + let harness = Harness(options: .sqlForTests) + var database = Self.sqlTree() + database.objects[0].isSelected = false + database.objects[0].optionValues = [false, false, false] + harness.apply([database]) + let identity = "obj:\(database.id.uuidString):\(database.objects[0].id.uuidString)" + + harness.pressSpace(on: try #require(harness.node(of: identity))) + + let users = try #require(harness.items.first?.objects.first) + #expect(users.isSelected) + #expect(users.optionValues == [true, true, true]) + } + + @Test("Space on a group that cannot be ticked and a row under it still ticks the row") + func spaceReachesARowUnderADisabledGroup() throws { + let harness = Harness(options: .sqlForTests) + let database = ExportDatabaseItem(name: "app", objects: [ + ExportObjectItem(name: "users", kind: .table, isSelected: true, optionValues: [true, true, true]), + ExportObjectItem(name: "recalc", kind: .routine, optionValues: [false, false, false]) + ]) + harness.apply([database]) + let group = try #require(harness.row(of: "group:\(database.id.uuidString):\(PluginExportObjectKind.routine.rawValue)")) + let routine = try #require(harness.row(of: "obj:\(database.id.uuidString):\(database.objects[1].id.uuidString)")) + harness.outlineView.selectRowIndexes(IndexSet([group, routine]), byExtendingSelection: false) + + harness.coordinator?.toggleSelectedRows() + + let recalc = try #require(harness.items.first?.objects.first { $0.name == "recalc" }) + #expect(recalc.isSelected) + #expect(recalc.optionValues == [true, true, false]) + } } diff --git a/apple/TableProTests/Views/Export/ExportTreeBuilderTests.swift b/apple/TableProTests/Views/Export/ExportTreeBuilderTests.swift index 82b18e660c..96d014b5b5 100644 --- a/apple/TableProTests/Views/Export/ExportTreeBuilderTests.swift +++ b/apple/TableProTests/Views/Export/ExportTreeBuilderTests.swift @@ -395,6 +395,26 @@ struct ExportTreeBuilderTests { #expect(archive.objects.first { $0.name == "purge" }?.isSelected == true) } + @Test("A reload keeps a table's row scope, so a format round trip cannot widen what it exports") + func reloadKeepsRowScope() async throws { + let builder = Self.builder( + typeId: Self.schemaTypeId, + preselection: .tables(names: [], scope: nil), + reader: Self.threeSchemaReader(), + supportedObjectKinds: Self.sqlKinds + ) + var before = try await builder.build(priorRows: [:]) + let databaseIndex = try #require(before.firstIndex { !$0.objects.isEmpty }) + let scope = PluginExportRowScope(filter: "total > 10", rowLimit: 5) + before[databaseIndex].objects[0].rowScope = scope + let name = before[databaseIndex].objects[0].name + + let after = try await builder.build(priorRows: ExportTreeBuilder.snapshots(of: before)) + + let restored = try #require(after.first { $0.name == before[databaseIndex].name }?.objects.first { $0.name == name }) + #expect(restored.rowScope == scope) + } + @Test("A schema holding the preselected table is expanded even when it is not the default one") func preselectedSchemaExpands() async throws { let reader = FakeReader( diff --git a/apple/TableProUITests/ExportUITests.swift b/apple/TableProUITests/ExportUITests.swift index 816fe2da1b..63207dd5d0 100644 --- a/apple/TableProUITests/ExportUITests.swift +++ b/apple/TableProUITests/ExportUITests.swift @@ -77,6 +77,67 @@ final class ExportUITests: UITestCase { XCTAssertFalse(markdown.contains("North"), "A data dictionary must hold no row") } + func testExportingEveryTableAsStructureOnly() throws { + let launch = try launchWithFixture() + let window = launch.window + let app = launch.app + let destination = try XCTUnwrap(sandboxRoot) + let exported = destination.appendingPathComponent("schema.sql") + + _ = openFromObjectBrowser("album", in: launch) + app.typeShortcut(.export) + + let sheet = window.children(matching: .sheet).firstMatch + XCTAssertTrue(sheet.staticTexts["1 table to export"].waitToExist(timeout: 30), "Export must open") + let format = element(sheet.popUpButtons, labelEndingWith: "Format") + XCTAssertTrue(format.waitToExist(timeout: 10), "The export sheet must offer a format menu") + if (format.value as? String) != "SQL" { + format.click() + let sql = app.menuItems["SQL"].firstMatch + XCTAssertTrue(sql.waitToExist(timeout: 10), "SQL must be an export format") + sql.click() + } + XCTAssertTrue(waitForPredicate(timeout: 10) { (format.value as? String) == "SQL" }, "The format must be SQL") + + let tree = sheet.outlines.firstMatch + XCTAssertTrue(tree.waitToExist(timeout: 10), "The export tree must show") + // The fixture's database row is named after its file's path. + let database = tree.checkBoxes.matching( + NSPredicate(format: "label BEGINSWITH %@ AND label ENDSWITH %@", "/", ".sqlite") + ).firstMatch + XCTAssertTrue(database.waitToExist(timeout: 10), "The database row must offer its own checkbox") + database.click() + XCTAssertTrue( + sheet.staticTexts["6 tables to export"].waitToExist(timeout: 10), + "Ticking the database must select every table" + ) + for column in ["Drop", "Data"] { + let box = tree.checkBoxes.matching( + NSPredicate(format: "label BEGINSWITH %@ AND label ENDSWITH %@", "\(column) for ", ".sqlite") + ).firstMatch + XCTAssertTrue(box.waitToExist(timeout: 10), "The database row must offer a \(column) checkbox") + box.click() + XCTAssertTrue( + waitForPredicate(timeout: 10) { (box.value as? Int) == 0 }, + "\(column) must turn off for the whole database" + ) + } + + save(from: sheet, to: exported, in: app) + + XCTAssertTrue( + waitForPredicate(timeout: 30) { FileManager.default.fileExists(atPath: exported.path) }, + "Export must write \(exported.lastPathComponent) into the chosen folder" + ) + let sql = try String(contentsOf: exported, encoding: .utf8) + XCTAssertEqual( + sql.components(separatedBy: "CREATE TABLE").count - 1, 6, + "The file must create every one of the fixture's six tables" + ) + XCTAssertFalse(sql.contains("INSERT INTO"), "A structure-only export must hold no row") + XCTAssertFalse(sql.contains("DROP TABLE"), "A structure-only export must drop nothing") + } + // Clicks Export…, names the file in the save panel, moves the panel to the file's folder and saves. private func save(from sheet: XCUIElement, to exported: URL, in app: XCUIApplication) { let destination = exported.deletingLastPathComponent() diff --git a/apple/changelog.d/3857.added.md b/apple/changelog.d/3857.added.md new file mode 100644 index 0000000000..3bcd523a64 --- /dev/null +++ b/apple/changelog.d/3857.added.md @@ -0,0 +1 @@ +In the export dialog, a database, schema or group row now sets Structure, Drop or Data for every object beneath it. diff --git a/apple/changelog.d/3857.changed.md b/apple/changelog.d/3857.changed.md new file mode 100644 index 0000000000..6e53aa2168 --- /dev/null +++ b/apple/changelog.d/3857.changed.md @@ -0,0 +1 @@ +Each export option is now its own column with a heading, and a table's checkbox only includes or excludes it. diff --git a/apple/changelog.d/3857.fixed.1.md b/apple/changelog.d/3857.fixed.1.md new file mode 100644 index 0000000000..922a2b9b0a --- /dev/null +++ b/apple/changelog.d/3857.fixed.1.md @@ -0,0 +1 @@ +Switching an SQL export to CSV and back dropped routines, triggers, sequences and types from the list. diff --git a/apple/changelog.d/3857.fixed.2.md b/apple/changelog.d/3857.fixed.2.md new file mode 100644 index 0000000000..4e59220855 --- /dev/null +++ b/apple/changelog.d/3857.fixed.2.md @@ -0,0 +1 @@ +A format change that reloaded the export list dropped each table's row filter. diff --git a/apple/changelog.d/3857.fixed.3.md b/apple/changelog.d/3857.fixed.3.md new file mode 100644 index 0000000000..0a35a1c719 --- /dev/null +++ b/apple/changelog.d/3857.fixed.3.md @@ -0,0 +1 @@ +The export dialog's Structure, Drop and Data headings sat off their checkboxes and were never translated. diff --git a/apple/changelog.d/3857.fixed.4.md b/apple/changelog.d/3857.fixed.4.md new file mode 100644 index 0000000000..d151628190 --- /dev/null +++ b/apple/changelog.d/3857.fixed.4.md @@ -0,0 +1 @@ +An MQL export with Data off showed its progress stuck at 0 rows. diff --git a/apple/changelog.d/3857.fixed.5.md b/apple/changelog.d/3857.fixed.5.md new file mode 100644 index 0000000000..4dd508c239 --- /dev/null +++ b/apple/changelog.d/3857.fixed.5.md @@ -0,0 +1 @@ +Space over a database and its rows in the export or backup list toggled the rows twice. diff --git a/apple/changelog.d/3857.fixed.6.md b/apple/changelog.d/3857.fixed.6.md new file mode 100644 index 0000000000..f01a404d6d --- /dev/null +++ b/apple/changelog.d/3857.fixed.6.md @@ -0,0 +1 @@ +Opening a query tab, or loading a query, from a query tab left the keyboard in the grid or sidebar. diff --git a/apple/changelog.d/3857.fixed.md b/apple/changelog.d/3857.fixed.md new file mode 100644 index 0000000000..8305ddc19f --- /dev/null +++ b/apple/changelog.d/3857.fixed.md @@ -0,0 +1 @@ +A saved export selection made under another format came back with every option turned on.