diff --git a/CHANGELOG.md b/CHANGELOG.md index ae5f46891..c450e44e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,12 +38,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Export, Transfer and Server-Side Export sheets resize, and the export object tree takes the extra room. (#2618) +- Cancel sits beside the action button in the export, transfer and server-side export footers. - PluginKit ABI 21. Every registry plugin needs rebuilding before or with this release. - Query Insights ranks on the time the database spent rather than on elapsed time. (#2503) - Export summary reports the warnings an export produced, instead of a bare "Export completed". (#2517) ### Fixed +- Restore Dump overwriting a database with no confirmation. +- Stopping an import applying at once, while stopping an export or a backup asks first. +- Progress bar stuck at zero and an "N/0 rows" label for the whole of a streaming query export. +- Checkboxes, pickers and text fields across the import and export sheets unnamed to VoiceOver. +- Import plugin options kept after cancelling the row importer, `Delete existing rows` among them. +- Escape not dismissing the export and import result alerts. +- A stopped import closing its progress sheet without saying what had already run. +- A failed restore leaving a partly-restored database without saying so. +- Oracle server-side export reported as written when the Data Pump job had only been started. +- Import stuck on a file that will not parse, with the parser's message as placeholder text. +- Backup save panel and password warning landing on whatever window was frontmost. - DuckDB aggregate, JSON and Parquet functions failing on a Mac that cannot reach `extensions.duckdb.org`. (#2626) - Last line of a helper process's output lost when it exits right after writing it. - Structure and trigger edits committing or rolling back a transaction left open in a query tab on the same connection. diff --git a/Plugins/CSVExportPlugin/CSVExportOptionsView.swift b/Plugins/CSVExportPlugin/CSVExportOptionsView.swift index 995c5c3cc..9fc9bb21f 100644 --- a/Plugins/CSVExportPlugin/CSVExportOptionsView.swift +++ b/Plugins/CSVExportPlugin/CSVExportOptionsView.swift @@ -30,7 +30,7 @@ struct CSVExportOptionsView: View { VStack(alignment: .leading, spacing: 10) { optionRow(String(localized: "Delimiter", bundle: .main)) { - Picker("", selection: $plugin.settings.delimiter) { + Picker(String(localized: "Delimiter", bundle: .main), selection: $plugin.settings.delimiter) { ForEach(CSVDelimiter.allCases) { delimiter in Text(delimiter.displayName).tag(delimiter) } @@ -41,7 +41,7 @@ struct CSVExportOptionsView: View { } optionRow(String(localized: "Quote", bundle: .main)) { - Picker("", selection: $plugin.settings.quoteHandling) { + Picker(String(localized: "Quote", bundle: .main), selection: $plugin.settings.quoteHandling) { ForEach(CSVQuoteHandling.allCases) { handling in Text(handling.rawValue).tag(handling) } @@ -52,7 +52,7 @@ struct CSVExportOptionsView: View { } optionRow(String(localized: "Line break", bundle: .main)) { - Picker("", selection: $plugin.settings.lineBreak) { + Picker(String(localized: "Line break", bundle: .main), selection: $plugin.settings.lineBreak) { ForEach(CSVLineBreak.allCases) { lineBreak in Text(lineBreak.rawValue).tag(lineBreak) } @@ -63,7 +63,7 @@ struct CSVExportOptionsView: View { } optionRow(String(localized: "Decimal", bundle: .main)) { - Picker("", selection: $plugin.settings.decimalFormat) { + Picker(String(localized: "Decimal", bundle: .main), selection: $plugin.settings.decimalFormat) { ForEach(CSVDecimalFormat.allCases) { format in Text(format.rawValue).tag(format) } diff --git a/Plugins/CSVImportPlugin/CSVImportOptionsView.swift b/Plugins/CSVImportPlugin/CSVImportOptionsView.swift index 28e9efa34..dbcf583c4 100644 --- a/Plugins/CSVImportPlugin/CSVImportOptionsView.swift +++ b/Plugins/CSVImportPlugin/CSVImportOptionsView.swift @@ -15,7 +15,7 @@ struct CSVImportOptionsView: View { GridRow { Text("Delimiter:") .gridColumnAlignment(.trailing) - Picker("", selection: Bindable(plugin).settings.delimiter) { + Picker(String(localized: "Delimiter", bundle: .main), selection: Bindable(plugin).settings.delimiter) { Text("Auto-detect").tag(CSVImportOptions.Delimiter.auto) Text("Comma (,)").tag(CSVImportOptions.Delimiter.comma) Text("Semicolon (;)").tag(CSVImportOptions.Delimiter.semicolon) @@ -29,7 +29,7 @@ struct CSVImportOptionsView: View { GridRow { Text("Quote character:") - Picker("", selection: Bindable(plugin).settings.quoteCharacter) { + Picker(String(localized: "Quote character", bundle: .main), selection: Bindable(plugin).settings.quoteCharacter) { Text("Double quote (\")").tag(CSVImportOptions.QuoteCharacter.doubleQuote) Text("Single quote (')").tag(CSVImportOptions.QuoteCharacter.singleQuote) } @@ -40,7 +40,7 @@ struct CSVImportOptionsView: View { GridRow { Text("Encoding:") - Picker("", selection: Bindable(plugin).settings.encoding) { + Picker(String(localized: "Encoding", bundle: .main), selection: Bindable(plugin).settings.encoding) { Text("Auto-detect").tag(CSVImportOptions.TextEncoding.auto) Text("UTF-8").tag(CSVImportOptions.TextEncoding.utf8) Text("ISO Latin 1").tag(CSVImportOptions.TextEncoding.isoLatin1) @@ -53,7 +53,7 @@ struct CSVImportOptionsView: View { GridRow { Text("On error:") - Picker("", selection: Bindable(plugin).settings.errorHandling) { + Picker(String(localized: "On error", bundle: .main), selection: Bindable(plugin).settings.errorHandling) { Text("Stop and Rollback").tag(ImportErrorHandling.stopAndRollback) Text("Stop and Commit").tag(ImportErrorHandling.stopAndCommit) Text("Skip and Continue").tag(ImportErrorHandling.skipAndContinue) diff --git a/Plugins/JSONExportPlugin/JSONExportOptionsView.swift b/Plugins/JSONExportPlugin/JSONExportOptionsView.swift index e92027c77..475e49588 100644 --- a/Plugins/JSONExportPlugin/JSONExportOptionsView.swift +++ b/Plugins/JSONExportPlugin/JSONExportOptionsView.swift @@ -15,7 +15,7 @@ struct JSONExportOptionsView: View { Spacer() - Picker("", selection: $plugin.settings.layout) { + Picker(String(localized: "Layout", bundle: .main), selection: $plugin.settings.layout) { ForEach(JSONExportLayout.allCases) { layout in Text(layout.label).tag(layout) } diff --git a/Plugins/MQLExportPlugin/MQLExportOptionsView.swift b/Plugins/MQLExportPlugin/MQLExportOptionsView.swift index 077d45107..6e6579ad6 100644 --- a/Plugins/MQLExportPlugin/MQLExportOptionsView.swift +++ b/Plugins/MQLExportPlugin/MQLExportOptionsView.swift @@ -26,7 +26,7 @@ struct MQLExportOptionsView: View { Spacer() - Picker("", selection: $plugin.settings.batchSize) { + Picker(String(localized: "Rows per insertMany", bundle: .main), selection: $plugin.settings.batchSize) { ForEach(Self.batchSizeOptions, id: \.self) { size in Text("\(size)") .tag(size) diff --git a/Plugins/MarkdownExportPlugin/MarkdownExportOptionsView.swift b/Plugins/MarkdownExportPlugin/MarkdownExportOptionsView.swift index 397e06c41..26f7ccfe7 100644 --- a/Plugins/MarkdownExportPlugin/MarkdownExportOptionsView.swift +++ b/Plugins/MarkdownExportPlugin/MarkdownExportOptionsView.swift @@ -12,7 +12,7 @@ struct MarkdownExportOptionsView: View { VStack(alignment: .leading, spacing: 8) { Toggle("Align columns", isOn: $plugin.settings.alignsColumns) .toggleStyle(.checkbox) - .help("Pads cells so the columns line up in the raw text. Widths come from the header and the first 200 rows") + .help("Pads cells so the columns line up in the raw text. Widths come from the header and the first 200 rows.") Toggle("Write each table's name as a heading", isOn: $plugin.settings.includesTableNames) .toggleStyle(.checkbox) diff --git a/Plugins/ParquetExportPlugin/ParquetExportModels.swift b/Plugins/ParquetExportPlugin/ParquetExportModels.swift index 6c0fa0223..2613ffadf 100644 --- a/Plugins/ParquetExportPlugin/ParquetExportModels.swift +++ b/Plugins/ParquetExportPlugin/ParquetExportModels.swift @@ -15,9 +15,9 @@ public enum ParquetCompression: String, Codable, CaseIterable, Sendable, Identif public var label: String { switch self { - case .snappy: return String(localized: "Snappy") - case .zstd: return String(localized: "Zstd") - case .gzip: return String(localized: "Gzip") + case .snappy: return "Snappy" + case .zstd: return "Zstd" + case .gzip: return "Gzip" case .uncompressed: return String(localized: "None") } } diff --git a/Plugins/ParquetExportPlugin/ParquetExportOptionsView.swift b/Plugins/ParquetExportPlugin/ParquetExportOptionsView.swift index 3735b4673..7bb174d3f 100644 --- a/Plugins/ParquetExportPlugin/ParquetExportOptionsView.swift +++ b/Plugins/ParquetExportPlugin/ParquetExportOptionsView.swift @@ -15,7 +15,7 @@ struct ParquetExportOptionsView: View { HStack { Text("Compression") Spacer() - Picker("", selection: $plugin.settings.compression) { + Picker(String(localized: "Compression", bundle: .main), selection: $plugin.settings.compression) { ForEach(ParquetCompression.allCases) { compression in Text(compression.label).tag(compression) } @@ -24,12 +24,12 @@ struct ParquetExportOptionsView: View { .labelsHidden() .frame(width: 130) } - .help("Snappy is what every Parquet reader supports. Zstd is smaller and needs a reader built with it") + .help("Snappy is what every Parquet reader supports. Zstd is smaller and needs a reader built with it.") HStack { Text("Rows per group") Spacer() - Picker("", selection: $plugin.settings.rowGroupSize) { + Picker(String(localized: "Rows per group", bundle: .main), selection: $plugin.settings.rowGroupSize) { ForEach(Self.rowGroupSizes, id: \.self) { size in Text(size.formatted()).tag(size) } diff --git a/Plugins/SQLExportPlugin/SQLExportOptionsView.swift b/Plugins/SQLExportPlugin/SQLExportOptionsView.swift index c02276486..bbdc4d04a 100644 --- a/Plugins/SQLExportPlugin/SQLExportOptionsView.swift +++ b/Plugins/SQLExportPlugin/SQLExportOptionsView.swift @@ -59,7 +59,7 @@ struct SQLExportOptionsView: View { Spacer() - Picker("", selection: $plugin.settings.batchSize) { + Picker(String(localized: "Rows per INSERT", bundle: .main), selection: $plugin.settings.batchSize) { ForEach(Self.batchSizeOptions, id: \.self) { size in Text(size == 1 ? String(localized: "1 (no batching)", bundle: .main) : "\(size)") .tag(size) @@ -77,7 +77,7 @@ struct SQLExportOptionsView: View { Spacer() - Picker("", selection: $plugin.settings.insertMode) { + Picker(String(localized: "On existing rows", bundle: .main), selection: $plugin.settings.insertMode) { ForEach(SQLExportInsertMode.allCases) { mode in Text(mode.label).tag(mode) } @@ -94,7 +94,7 @@ struct SQLExportOptionsView: View { Spacer() - Picker("", selection: $plugin.settings.splitSizeMegabytes) { + Picker(String(localized: "Split every", bundle: .main), selection: $plugin.settings.splitSizeMegabytes) { ForEach(Self.splitSizeOptions, id: \.self) { size in Text(size == 0 ? String(localized: "One file", bundle: .main) : "\(size) MB") .tag(size) diff --git a/TablePro/Core/Database/NativeDumpService.swift b/TablePro/Core/Database/NativeDumpService.swift index e0c13bd4d..9d21c707a 100644 --- a/TablePro/Core/Database/NativeDumpService.swift +++ b/TablePro/Core/Database/NativeDumpService.swift @@ -26,7 +26,10 @@ enum NativeDumpState: Equatable { case running(database: String, fileURL: URL, bytesProcessed: Int64, totalBytes: Int64?) case cancelling case finished(database: String, fileURL: URL, bytesProcessed: Int64) - case failed(message: String) + /// A restore that fails part way through has already replayed some of the dump, and the + /// target is left in whatever state that reached. A backup writes only to its own file, which + /// is removed, so nothing of the user's is touched. + case failed(message: String, targetMayBeModified: Bool) case cancelled } @@ -206,7 +209,7 @@ final class NativeDumpService { return (name, path) }).first else { throw NativeDumpError.binaryNotFound( - name: candidates.joined(separator: String(localized: " or ")), + name: candidates.formatted(.list(type: .or)), installHint: descriptor.installHint ) } @@ -397,7 +400,7 @@ final class NativeDumpService { let summary = result.stderr.isEmpty ? String(format: String(localized: "Process exited with code %d"), Int(result.exitCode)) : result.stderr - setState(.failed(message: summary)) + setState(.failed(message: summary, targetMayBeModified: kind == .restore)) Self.logger.error("\(self.kind == .backup ? "pg_dump" : "pg_restore", privacy: .public) failed code=\(result.exitCode) db=\(database, privacy: .public) stderr=\(result.stderr)") } diff --git a/TablePro/Core/Database/ServerSideExport.swift b/TablePro/Core/Database/ServerSideExport.swift index cd4aac433..a9c1e5a28 100644 --- a/TablePro/Core/Database/ServerSideExport.swift +++ b/TablePro/Core/Database/ServerSideExport.swift @@ -94,7 +94,7 @@ enum ServerSideExport { ) -> String? { switch request.destination { case .oracleDirectory(let directory): - return oracleStatement(request, directory: directory) + return oracleStatement(request, directory: directory, escape: escapeLiteral) case .snowflakeStage(let stage): return snowflakeStatement(request, stage: stage, quote: quoteIdentifier, escape: escapeLiteral) case .googleCloudStorage(let uri): @@ -109,21 +109,23 @@ enum ServerSideExport { /// caller is told where rather than handed anything. private static func oracleStatement( _ request: Request, - directory: String + directory: String, + escape: (String) -> String ) -> String? { guard !directory.isEmpty else { return nil } - let dumpFile = "\(sanitizedFileStem(request.table)).dmp" - let logFile = "\(sanitizedFileStem(request.table)).log" + let stem = sanitizedFileStem(request.table) + let directoryLiteral = escape(directory.uppercased()) + let tableFilter = nestedLiteral(request.table.uppercased(), escape: escape) return """ DECLARE handle NUMBER; BEGIN - handle := DBMS_DATAPUMP.OPEN('EXPORT', 'TABLE', NULL, '\(sanitizedFileStem(request.table))'); - DBMS_DATAPUMP.ADD_FILE(handle, '\(dumpFile)', '\(directory.uppercased())'); - DBMS_DATAPUMP.ADD_FILE(handle, '\(logFile)', '\(directory.uppercased())', NULL, + handle := DBMS_DATAPUMP.OPEN('EXPORT', 'TABLE', NULL, '\(escape(stem))'); + DBMS_DATAPUMP.ADD_FILE(handle, '\(escape(stem)).dmp', '\(directoryLiteral)'); + DBMS_DATAPUMP.ADD_FILE(handle, '\(escape(stem)).log', '\(directoryLiteral)', NULL, DBMS_DATAPUMP.KU$_FILE_TYPE_LOG_FILE); - DBMS_DATAPUMP.METADATA_FILTER(handle, 'NAME_EXPR', 'IN (''\(request.table.uppercased())'')'); - DBMS_DATAPUMP.METADATA_FILTER(handle, 'SCHEMA_EXPR', 'IN (''\(schemaFilter(request))'')'); + DBMS_DATAPUMP.METADATA_FILTER(handle, 'NAME_EXPR', 'IN (''\(tableFilter)'')'); + DBMS_DATAPUMP.METADATA_FILTER(handle, 'SCHEMA_EXPR', \(schemaFilterExpression(request, escape: escape))); DBMS_DATAPUMP.START_JOB(handle); DBMS_DATAPUMP.DETACH(handle); END; @@ -131,10 +133,21 @@ enum ServerSideExport { } /// Data Pump filters by schema separately from table, so an unqualified request exports from - /// whatever schema the session is in, which is what `USER` names. - private static func schemaFilter(_ request: Request) -> String { - guard let schema = request.schema, !schema.isEmpty else { return "'' || USER || ''" } - return schema.uppercased() + /// whatever schema the session is in, which is what `USER` names. `USER` is concatenated in + /// PL/SQL rather than written inside a literal, because a literal cannot hold an identifier. + private static func schemaFilterExpression(_ request: Request, escape: (String) -> String) -> String { + guard let schema = request.schema, !schema.isEmpty else { + return "'IN (''' || USER || ''')'" + } + return "'IN (''\(nestedLiteral(schema.uppercased(), escape: escape))'')'" + } + + /// A value written inside a literal that is itself inside a literal, which is what Data Pump's + /// `NAME_EXPR` and `SCHEMA_EXPR` are. A quote has to survive both levels, so it is escaped + /// twice. An Oracle identifier may legally hold one, and left raw it closes the outer literal + /// early and hands the rest of the name to the parser as PL/SQL. + private static func nestedLiteral(_ value: String, escape: (String) -> String) -> String { + escape(escape(value)) } // MARK: - Snowflake diff --git a/TablePro/Core/Services/Export/ExportFormatCatalog.swift b/TablePro/Core/Services/Export/ExportFormatCatalog.swift new file mode 100644 index 000000000..e9b0e92ed --- /dev/null +++ b/TablePro/Core/Services/Export/ExportFormatCatalog.swift @@ -0,0 +1,53 @@ +// +// ExportFormatCatalog.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +/// How the export dialog orders and describes the formats it offers. +/// +/// A format that is not listed here is still shown: it sorts after the curated ones by display +/// name and describes itself from its own `pluginDescription`. A registry format installed after +/// this app was built would otherwise tie with every other unknown format at the end of the list +/// and show no description at all, which is how Parquet shipped. +internal enum ExportFormatCatalog { + private static let displayOrder = [ + "csv", "json", "sql", "xlsx", "md", "html", "xml", "parquet", "mql" + ] + + internal static func sorted(_ plugins: [any ExportFormatPlugin]) -> [any ExportFormatPlugin] { + plugins.sorted { first, second in + let firstRank = rank(of: type(of: first).formatId) + let secondRank = rank(of: type(of: second).formatId) + guard firstRank == secondRank else { return firstRank < secondRank } + return type(of: first).formatDisplayName + .localizedCaseInsensitiveCompare(type(of: second).formatDisplayName) == .orderedAscending + } + } + + internal static func description(for plugin: any ExportFormatPlugin) -> String { + let pluginType = type(of: plugin) + return curatedDescription(for: pluginType.formatId) ?? pluginType.pluginDescription + } + + private static func rank(of formatId: String) -> Int { + displayOrder.firstIndex(of: formatId) ?? displayOrder.count + } + + private static func curatedDescription(for formatId: String) -> String? { + switch formatId { + case "csv": String(localized: "Comma-separated values. Compatible with Excel and most tools.") + case "json": String(localized: "Structured data format. Ideal for APIs and web applications.") + case "sql": String(localized: "SQL INSERT statements. Use to recreate data in another database.") + case "xlsx": String(localized: "Excel spreadsheet with formatting support.") + case "md": String(localized: "Markdown tables. Paste into a README, an issue or a wiki.") + case "html": String(localized: "An HTML table. Open in a browser or paste into a page.") + case "xml": String(localized: "One element per row. Use where a parser expects XML.") + case "parquet": String(localized: "Columnar format. Read by DuckDB, Spark, pandas and BigQuery.") + case "mql": String(localized: "MongoDB query language. Use to import into MongoDB.") + default: nil + } + } +} diff --git a/TablePro/Core/Services/Export/ExportService.swift b/TablePro/Core/Services/Export/ExportService.swift index 9ab8ee0eb..31b0b4fa4 100644 --- a/TablePro/Core/Services/Export/ExportService.swift +++ b/TablePro/Core/Services/Export/ExportService.swift @@ -376,8 +376,8 @@ final class ExportService { } } if failedCount > 0 { - Self.logger.warning("\(failedCount) table(s) failed row count - progress indicator may be inaccurate") - state.statusMessage = String(format: String(localized: "Progress estimated (%d table(s) could not be counted)"), failedCount) + Self.logger.warning("\(failedCount) tables failed row count, the progress indicator may be inaccurate") + state.statusMessage = Self.estimatedProgressMessage(uncountedTables: failedCount) } return total } @@ -418,9 +418,18 @@ final class ExportService { } if failedCount > 0 { - Self.logger.warning("\(failedCount) table(s) failed row count - progress indicator may be inaccurate") - state.statusMessage = String(format: String(localized: "Progress estimated (%d table(s) could not be counted)"), failedCount) + Self.logger.warning("\(failedCount) tables failed row count, the progress indicator may be inaccurate") + state.statusMessage = Self.estimatedProgressMessage(uncountedTables: failedCount) } return total } + + /// Counts pick between an explicit singular and plural key. Automatic grammar agreement is a + /// SwiftUI `Text` facility: `String(localized:)` returns the markup verbatim. + private static func estimatedProgressMessage(uncountedTables: Int) -> String { + let template = uncountedTables == 1 + ? String(localized: "Progress estimated (%lld table could not be counted)") + : String(localized: "Progress estimated (%lld tables could not be counted)") + return String(format: template, Int64(uncountedTables)) + } } diff --git a/TablePro/Core/Services/Export/TableTransferService.swift b/TablePro/Core/Services/Export/TableTransferService.swift index 967956156..f50ea9d41 100644 --- a/TablePro/Core/Services/Export/TableTransferService.swift +++ b/TablePro/Core/Services/Export/TableTransferService.swift @@ -69,6 +69,12 @@ final class TableTransferService { private var isCancelled = false + /// Cleared once, before the run's first cancellable step. The sheet reads both sides' columns + /// before `transfer()` is reached, and a Stop pressed during that read has to survive into it. + func prepareForRun() { + isCancelled = false + } + func cancel() { isCancelled = true } @@ -118,8 +124,10 @@ final class TableTransferService { let rowObjects = request.objects.filter { $0.kind.carriesRows } guard !rowObjects.isEmpty else { throw TableTransferError.noTablesSelected } + /// The flag is cleared on the way out, never on the way in. A Stop pressed while the sheet + /// was still reading both sides' columns arrives before this line, and clearing it here + /// threw that press away and started the transfer the user had just stopped. state = TableTransferState(isTransferring: true, totalTables: rowObjects.count) - isCancelled = false defer { state.isTransferring = false isCancelled = false diff --git a/TablePro/Core/Services/Operations/OperationCompletionCopy.swift b/TablePro/Core/Services/Operations/OperationCompletionCopy.swift index 68d3ea96f..b18ca764f 100644 --- a/TablePro/Core/Services/Operations/OperationCompletionCopy.swift +++ b/TablePro/Core/Services/Operations/OperationCompletionCopy.swift @@ -95,10 +95,18 @@ internal enum OperationCompletionCopy { /// A notification body is not an error dialog. The HIG is explicit that an alert, not a /// notification, carries an error message, so this announces the outcome and leaves the full /// text to the inline error the tab already shows. + /// Flattened before it is cut. A dump tool writes its diagnosis over several lines, and a + /// head-truncated block of those reaches the notification as the first line and a half of raw + /// stderr, with the sentence that names the cause below the cut. private static func truncated(_ reason: String) -> String { + let flattened = reason + .split(whereSeparator: \.isNewline) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + .joined(separator: " ") let limit = 120 - let bridged = reason as NSString - guard bridged.length > limit else { return reason } + let bridged = flattened as NSString + guard bridged.length > limit else { return flattened } return bridged.substring(to: limit).trimmingCharacters(in: .whitespaces) + "…" } } diff --git a/TablePro/Views/Backup/BackupDatabaseFlow.swift b/TablePro/Views/Backup/BackupDatabaseFlow.swift index cc85a82aa..2865d3861 100644 --- a/TablePro/Views/Backup/BackupDatabaseFlow.swift +++ b/TablePro/Views/Backup/BackupDatabaseFlow.swift @@ -23,11 +23,15 @@ struct BackupDatabaseFlow: View { @State private var service = NativeDumpService(kind: .backup) @State private var phase: Phase = .pickDatabase + /// The window this flow is hosted in. `NSApp.keyWindow` at the moment a panel is presented is + /// whatever is frontmost, which during a sheet transition is not this flow's own window. + @State private var hostWindow: NSWindow? + private enum Phase: Equatable { case pickDatabase case running(database: String, totalBytes: Int64?) case finished(database: String, destination: URL, bytes: Int64) - case failed(message: String) + case failed(message: String, targetMayBeModified: Bool) case cancelled } @@ -52,10 +56,11 @@ struct BackupDatabaseFlow: View { onClose: { isPresented = false }, onShowInFinder: { NSWorkspace.shared.activateFileViewerSelecting([destination]) } ) - case .failed(let message): + case .failed(let message, let targetMayBeModified): BackupResultSheet( kind: .backup, - outcome: .failure(message: message), + outcome: .failure( + message: message, targetMayBeModified: targetMayBeModified), onClose: { isPresented = false }, onShowInFinder: nil ) @@ -68,6 +73,9 @@ struct BackupDatabaseFlow: View { ) } } + .background { + WindowAccessor { window in hostWindow = window } + } .onChange(of: serviceState) { _, newState in handleServiceStateChange(newState) } @@ -109,8 +117,8 @@ struct BackupDatabaseFlow: View { case .finished(let database, let fileURL, let bytes): phase = .finished(database: database, destination: fileURL, bytes: bytes) reportBackupFinished(.succeeded(OperationSummary(fileURL: fileURL)), database: database) - case .failed(let message): - phase = .failed(message: message) + case .failed(let message, let targetMayBeModified): + phase = .failed(message: message, targetMayBeModified: targetMayBeModified) reportBackupFinished(.failed(reason: message), database: backupDatabase) case .cancelled: phase = .cancelled @@ -149,9 +157,8 @@ struct BackupDatabaseFlow: View { savePanel.title = String(localized: "Save Dump") savePanel.message = String(format: String(localized: "Choose where to save the dump of \u{201C}%@\u{201D}."), database) - let window = NSApp.keyWindow let response: NSApplication.ModalResponse - if let window { + if let window = AlertHelper.resolveWindow(hostWindow) { response = await savePanel.beginSheetModal(for: window) } else { response = savePanel.runModal() @@ -182,7 +189,7 @@ struct BackupDatabaseFlow: View { totalBytesEstimate: totalBytes ) } catch { - phase = .failed(message: error.localizedDescription) + phase = .failed(message: error.localizedDescription, targetMayBeModified: false) } } @@ -197,16 +204,17 @@ struct BackupDatabaseFlow: View { ConnectionStorage.shared.loadPassword(for: connection.id) != nil else { return true } - let alert = NSAlert() - alert.alertStyle = .warning - alert.messageText = String(localized: "This tool takes your password on its command line.") - alert.informativeText = String(localized: "SqlPackage has no other way to receive one, so while the dump runs the password is readable by other processes on this Mac. Windows or Entra authentication avoids it.") - alert.addButton(withTitle: String(localized: "Continue")) - alert.addButton(withTitle: String(localized: "Cancel")) - guard let window = NSApp.keyWindow else { - return alert.runModal() == .alertFirstButtonReturn - } - return await alert.beginSheetModal(for: window) == .alertFirstButtonReturn + return await AlertHelper.confirm( + title: String(localized: "This tool takes your password on its command line."), + message: String( + localized: """ + SqlPackage has no other way to receive one, so while the dump runs the password \ + is readable by other processes on this Mac. Windows or Entra authentication \ + avoids it. + """), + confirmButton: String(localized: "Continue"), + window: hostWindow + ) } /// The extension follows the engine's own archive format, so a MySQL dump is offered as `.sql` diff --git a/TablePro/Views/Backup/BackupResultSheet.swift b/TablePro/Views/Backup/BackupResultSheet.swift index 8123bab70..8da3376d9 100644 --- a/TablePro/Views/Backup/BackupResultSheet.swift +++ b/TablePro/Views/Backup/BackupResultSheet.swift @@ -16,7 +16,7 @@ struct BackupResultSheet: View { enum Outcome { case backupSuccess(database: String, destination: URL, bytes: Int64) case restoreSuccess(database: String, source: URL) - case failure(message: String) + case failure(message: String, targetMayBeModified: Bool) case cancelled } @@ -52,14 +52,24 @@ struct BackupResultSheet: View { } } .padding(24) - .frame(width: 420) + .frame(minWidth: 420) .background(Color(nsColor: .windowBackgroundColor)) } + private static let partialStateWarning = String( + localized: "The target database may be in a partial state. Review it and clean up as needed.") + @ViewBuilder private var detailView: some View { switch outcome { - case .failure(let message): + case .failure(let message, let targetMayBeModified): + if targetMayBeModified { + Text(Self.partialStateWarning) + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } ScrollView { Text(message) .font(.system(.callout, design: .monospaced)) @@ -144,13 +154,13 @@ struct BackupResultSheet: View { database, source.path ) - case .failure(let message): + case .failure(let message, _): return message case .cancelled: switch kind { case .backup: return nil case .restore: - return String(localized: "The target database may be in a partial state. Review the database and clean up as needed.") + return Self.partialStateWarning } } } @@ -184,7 +194,9 @@ struct BackupResultSheet: View { #Preview("Restore Failure") { BackupResultSheet( kind: .restore, - outcome: .failure(message: "pg_restore: error: could not connect to database \"missing\": FATAL: database does not exist"), + outcome: .failure( + message: "pg_restore: error: could not connect to database \"missing\": FATAL: database does not exist", + targetMayBeModified: true), onClose: {}, onShowInFinder: nil ) diff --git a/TablePro/Views/Backup/RestoreDatabaseFlow.swift b/TablePro/Views/Backup/RestoreDatabaseFlow.swift index 414f349fe..db4c437b7 100644 --- a/TablePro/Views/Backup/RestoreDatabaseFlow.swift +++ b/TablePro/Views/Backup/RestoreDatabaseFlow.swift @@ -9,12 +9,13 @@ struct RestoreDatabaseFlow: View { @State private var service = NativeDumpService(kind: .restore) @State private var phase: Phase = .pickDatabase + @State private var hostWindow: NSWindow? private enum Phase: Equatable { case pickDatabase case running(database: String) case finished(database: String) - case failed(message: String) + case failed(message: String, targetMayBeModified: Bool) case cancelled } @@ -39,10 +40,11 @@ struct RestoreDatabaseFlow: View { onClose: { isPresented = false }, onShowInFinder: nil ) - case .failed(let message): + case .failed(let message, let targetMayBeModified): BackupResultSheet( kind: .restore, - outcome: .failure(message: message), + outcome: .failure( + message: message, targetMayBeModified: targetMayBeModified), onClose: { isPresented = false }, onShowInFinder: nil ) @@ -55,6 +57,9 @@ struct RestoreDatabaseFlow: View { ) } } + .background { + WindowAccessor { window in hostWindow = window } + } .onChange(of: serviceState) { _, newState in handleServiceStateChange(newState) } @@ -105,8 +110,8 @@ struct RestoreDatabaseFlow: View { phase = .running(database: database) case .finished(let database, _, _): phase = .finished(database: database) - case .failed(let message): - phase = .failed(message: message) + case .failed(let message, let targetMayBeModified): + phase = .failed(message: message, targetMayBeModified: targetMayBeModified) case .cancelled: phase = .cancelled case .idle, .cancelling: @@ -114,12 +119,28 @@ struct RestoreDatabaseFlow: View { } } + /// A restore replays a dump into a database that already has contents, and the tools it drives + /// do not ask. Picking a database in the list used to be the last step before the first write. private func startRestore(database: String) async { + guard await AlertHelper.confirmDestructive( + title: String( + format: String(localized: "Restore into \u{201C}%@\u{201D}?"), database), + message: String( + localized: """ + The dump is replayed into this database. Objects it names are overwritten and \ + the change cannot be undone. + """), + confirmButton: String(localized: "Restore"), + window: hostWindow + ) else { + phase = .pickDatabase + return + } phase = .running(database: database) do { try await service.start(connection: connection, database: database, fileURL: sourceURL) } catch { - phase = .failed(message: error.localizedDescription) + phase = .failed(message: error.localizedDescription, targetMayBeModified: false) } } } diff --git a/TablePro/Views/Backup/ServerSideExportSheet.swift b/TablePro/Views/Backup/ServerSideExportSheet.swift index 7a2935315..154ae1385 100644 --- a/TablePro/Views/Backup/ServerSideExportSheet.swift +++ b/TablePro/Views/Backup/ServerSideExportSheet.swift @@ -29,6 +29,8 @@ struct ServerSideExportSheet: View { @State private var errorMessage: String? @State private var completion: String? @State private var hostWindow: NSWindow? + @State private var runTask: Task? + @State private var isCancelling = false private var formats: [ServerSideExport.Format] { ServerSideExport.supportedFormats(for: connection.type) @@ -103,12 +105,15 @@ struct ServerSideExportSheet: View { footer } - .frame(width: 440) + .frame(minWidth: 440) .background(Color(nsColor: .windowBackgroundColor)) .background { WindowAccessor { window in hostWindow = window } } .task { await load() } .onExitCommand { - guard !isRunning else { return } + guard !isRunning else { + stop() + return + } isPresented = false } } @@ -125,8 +130,9 @@ struct ServerSideExportSheet: View { Text(destinationLabel) .font(.subheadline) .foregroundStyle(.secondary) - TextField(destinationPrompt, text: $destinationText) + TextField(destinationLabel, text: $destinationText, prompt: Text(destinationPrompt)) .textFieldStyle(.roundedBorder) + .labelsHidden() Text(destinationHelp) .font(.caption) .foregroundStyle(.secondary) @@ -159,15 +165,23 @@ struct ServerSideExportSheet: View { } private var footer: some View { - HStack { - Button("Cancel") { isPresented = false } - .disabled(isRunning) - Spacer() + DialogFooter { if isRunning { ProgressView().scaleEffect(0.7) + Text(isCancelling + ? String(localized: "Stopping\u{2026}") + : String(localized: "The server is writing the file\u{2026}")) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } actions: { + Button(isRunning ? String(localized: "Stop") : String(localized: "Cancel")) { + if isRunning { stop() } else { isPresented = false } } + Button("Export") { - Task { await run() } + runTask = Task { await run() } } .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) @@ -177,6 +191,38 @@ struct ServerSideExportSheet: View { .padding(.vertical, 12) } + /// Oracle's Data Pump block ends in `DETACH`, so the statement returns once the job has been + /// queued and the file is not written yet. Snowflake and BigQuery both block until their unload + /// finishes, so for those the file exists by the time this is read. + private static func completionMessage( + for type: DatabaseType, + table: String, + destination: String + ) -> String { + guard type == .oracle else { + return String( + format: String(localized: "The server wrote %1$@ to %2$@."), table, destination) + } + return String( + format: String( + localized: "A Data Pump job for %1$@ was started, writing to %2$@. Watch DBA_DATAPUMP_JOBS for its progress."), + table, + destination) + } + + /// Asks the driver to cancel and stops waiting either way. `cancelQuery()` is a no-op on some + /// engines and `Task.cancel()` cannot interrupt a driver blocked in a C call, so the sheet says + /// it is stopping rather than claiming the server stopped. + @MainActor + private func stop() { + guard isRunning, !isCancelling else { return } + isCancelling = true + if let driver = DatabaseManager.shared.driver(for: connection.id) { + try? driver.cancelQuery() + } + runTask?.cancel() + } + @MainActor private func load() async { format = formats.first ?? .csv @@ -207,7 +253,12 @@ struct ServerSideExportSheet: View { errorMessage = nil completion = nil isRunning = true - defer { isRunning = false } + isCancelling = false + defer { + isRunning = false + isCancelling = false + runTask = nil + } guard let driver = DatabaseManager.shared.driver(for: connection.id) else { errorMessage = String(localized: "Not connected.") @@ -231,11 +282,10 @@ struct ServerSideExportSheet: View { do { _ = try await driver.execute(query: statement) - completion = String( - format: String(localized: "The server wrote %1$@ to %2$@."), - selectedTable, - destinationText) + completion = Self.completionMessage( + for: connection.type, table: selectedTable, destination: destinationText) } catch { + guard !isCancelling else { return } Self.logger.warning("Server-side export failed: \(error.localizedDescription)") errorMessage = error.localizedDescription } diff --git a/TablePro/Views/Components/TransferResultAlert.swift b/TablePro/Views/Components/TransferResultAlert.swift index 3e12c7929..79b47763b 100644 --- a/TablePro/Views/Components/TransferResultAlert.swift +++ b/TablePro/Views/Components/TransferResultAlert.swift @@ -34,7 +34,10 @@ internal enum TransferResultAlert { alert.alertStyle = warnings.isEmpty ? .informational : .warning alert.informativeText = warnings.joined(separator: "\n\n") alert.addButton(withTitle: String(localized: "Open in Finder")) - alert.addButton(withTitle: String(localized: "Done")) + /// `NSAlert` binds Escape by matching a button's title against "Cancel", which stops + /// matching in every localized build and never matched "Done" at all. Without this the + /// alert answers no key but Return, which opens Finder. + AlertHelper.addCancelButton(to: alert, title: String(localized: "Done")) alert.showsSuppressionButton = warnings.isEmpty alert.suppressionButton?.title = String(localized: "Do not show this again") @@ -48,6 +51,58 @@ internal enum TransferResultAlert { AlertHelper.present(alert, in: window, completion: deliver) } + /// A transfer writes into another connection and leaves nothing on disk, so there is no folder + /// to open and no file to name. It still has to say how much moved and where, which it did not: + /// the sheet used to close on success and report nothing at all. + internal static func presentTransferSuccess( + tableCount: Int, + rowCount: Int, + destinationName: String, + warnings: [String], + window: NSWindow?, + completion: @escaping @MainActor () -> Void + ) { + let alert = NSAlert() + alert.messageText = warnings.isEmpty + ? String(localized: "Transfer completed") + : String(localized: "Transfer completed with warnings") + alert.alertStyle = warnings.isEmpty ? .informational : .warning + alert.informativeText = ([transferSummary( + tableCount: tableCount, rowCount: rowCount, destinationName: destinationName + )] + warnings).joined(separator: "\n\n") + AlertHelper.addCancelButton(to: alert, title: String(localized: "Done")) + AlertHelper.present(alert, in: window) { _ in completion() } + } + + private static func transferSummary( + tableCount: Int, + rowCount: Int, + destinationName: String + ) -> String { + let template = tableCount == 1 + ? String(localized: "%1$lld rows from 1 table written to %2$@.") + : String(localized: "%1$lld rows from %3$lld tables written to %2$@.") + return String(format: template, Int64(rowCount), destinationName, Int64(tableCount)) + } + + /// A stopped import leaves whatever it already ran committed, and used to close its progress + /// sheet without saying so. Stopping looked the same as importing nothing. + internal static func presentImportCancelled( + executedStatements: Int, + window: NSWindow?, + completion: @escaping @MainActor () -> Void + ) { + let alert = NSAlert() + alert.messageText = String(localized: "Import stopped") + alert.alertStyle = .warning + let template = executedStatements == 1 + ? String(localized: "%lld statement had already run and stays committed.") + : String(localized: "%lld statements had already run and stay committed.") + alert.informativeText = String(format: template, Int64(executedStatements)) + AlertHelper.addCancelButton(to: alert, title: String(localized: "Done")) + AlertHelper.present(alert, in: window) { _ in completion() } + } + internal static func presentImportSuccess( result: PluginImportResult?, window: NSWindow?, @@ -62,7 +117,7 @@ internal enum TransferResultAlert { : String(localized: "Import completed") alert.alertStyle = skipped > 0 ? .warning : .informational alert.informativeText = importSummary(result) - alert.addButton(withTitle: String(localized: "Done")) + AlertHelper.addCancelButton(to: alert, title: String(localized: "Done")) let errors = result?.errors ?? [] if !errors.isEmpty { diff --git a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift index 2e267c61c..d49548b87 100644 --- a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift +++ b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift @@ -85,7 +85,7 @@ struct DatabaseSwitcherSheet: View { private var primaryButtonLabel: String { switch mode { case .backup: return String(localized: "Choose Destination…") - case .restore: return String(localized: "Restore…") + case .restore: return String(localized: "Restore") } } diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index 68ee7fdfd..2b2559bc3 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -2,9 +2,6 @@ // ExportDialog.swift // TablePro // -// Main export dialog for exporting tables using format plugins. -// Features a split layout with table selection tree on the left and format options on the right. -// import AppKit import os @@ -12,7 +9,6 @@ import SwiftUI import TableProPluginKit import UniformTypeIdentifiers -/// Main export dialog view struct ExportDialog: View { private static let logger = Logger(subsystem: "com.TablePro", category: "ExportDialog") @@ -78,6 +74,16 @@ struct ExportDialog: View { return 0 } + /// The name the progress sheet puts in front of the user. A streaming query has no current + /// table, so it is named by the file it is being written to instead of by an empty string. + private var progressSubject: String { + let currentTable = exportService?.state.currentTable ?? "" + guard currentTable.isEmpty else { return currentTable } + return config.fileName.isEmpty + ? String(localized: "Query results") + : config.fileName + } + private var preselection: ExportPreselection { if case .tables(_, let preselection) = mode { return preselection @@ -92,21 +98,25 @@ struct ExportDialog: View { HStack(spacing: 0) { if !isQueryResultsMode { tableSelectionView - .frame(minWidth: leftPanelWidth) + .frame(minWidth: leftPanelWidth, maxWidth: .infinity) Divider() } exportOptionsView - .frame(width: 280) + .frame(width: Self.optionsPanelWidth) } - .frame(height: 420) + .frame(minHeight: 320, idealHeight: 420, maxHeight: .infinity) Divider() footerView } - .frame(width: dialogWidth) + .frame( + minWidth: dialogWidth, + idealWidth: dialogWidth, + maxWidth: isQueryResultsMode ? dialogWidth : .infinity + ) .background(Color(nsColor: .windowBackgroundColor)) .background { WindowAccessor { window in @@ -157,7 +167,7 @@ struct ExportDialog: View { } .sheet(isPresented: $showProgressDialog) { ExportProgressView( - tableName: exportService?.state.currentTable ?? "", + subject: progressSubject, tableIndex: exportService?.state.currentTableIndex ?? 0, totalTables: exportService?.state.totalTables ?? 0, processedRows: exportService?.state.processedRows ?? 0, @@ -188,7 +198,7 @@ struct ExportDialog: View { private var availableFormats: [any ExportFormatPlugin] { let dbTypeId = connection.type.rawValue - return PluginManager.shared.allExportPlugins() + let supported = PluginManager.shared.allExportPlugins() .filter { plugin in let pluginType = type(of: plugin) if !pluginType.supportedDatabaseTypeIds.isEmpty { @@ -199,11 +209,7 @@ struct ExportDialog: View { } return true } - .sorted { a, b in - let aIndex = Self.formatDisplayOrder.firstIndex(of: type(of: a).formatId) ?? Int.max - let bIndex = Self.formatDisplayOrder.firstIndex(of: type(of: b).formatId) ?? Int.max - return aIndex < bIndex - } + return ExportFormatCatalog.sorted(supported) } private var availableFormatIds: [String] { @@ -225,13 +231,17 @@ struct ExportDialog: View { // MARK: - Layout Constants + /// The options column is an inspector: it holds one control per option and gains nothing from + /// being wider. The tree beside it takes every point the user drags the sheet out to. + private static let optionsPanelWidth: CGFloat = 280 + private var leftPanelWidth: CGFloat { guard let plugin = currentPlugin else { return 240 } return type(of: plugin).perTableOptionColumns.isEmpty ? 240 : 380 } private var dialogWidth: CGFloat { - isQueryResultsMode ? 280 : leftPanelWidth + 280 + isQueryResultsMode ? Self.optionsPanelWidth : leftPanelWidth + Self.optionsPanelWidth } // MARK: - Table Selection View @@ -394,7 +404,7 @@ struct ExportDialog: View { HStack { Spacer() - Picker("", selection: $config.formatId) { + Picker(String(localized: "Format"), selection: $config.formatId) { ForEach(availableFormatIds, id: \.self) { formatId in if let plugin = PluginManager.shared.exportPlugin(forFormat: formatId) { Text(type(of: plugin).formatDisplayName).tag(formatId) @@ -406,11 +416,13 @@ struct ExportDialog: View { Spacer() } - let description = formatDescription(for: config.formatId) - if !description.isEmpty { - Text(description) - .font(.subheadline) - .foregroundStyle(.secondary) + if let plugin = currentPlugin { + let description = ExportFormatCatalog.description(for: plugin) + if !description.isEmpty { + Text(description) + .font(.subheadline) + .foregroundStyle(.secondary) + } } } @@ -420,11 +432,11 @@ struct ExportDialog: View { .font(.subheadline) .foregroundStyle(.secondary) } else if isQueryResultsMode { - Text("\(queryResultsRowCount) row\(queryResultsRowCount == 1 ? "" : "s") to export") + Text("\(queryResultsRowCount) ^[row](inflect: true) to export") .font(.subheadline) .foregroundStyle(.secondary) } else { - Text("\(exportableCount) table\(exportableCount == 1 ? "" : "s") to export") + Text("\(exportableCount) ^[table](inflect: true) to export") .font(.subheadline) .foregroundStyle(.secondary) @@ -454,7 +466,7 @@ struct ExportDialog: View { Button("Reset to Defaults") { resetCurrentFormatSettings() } - .buttonStyle(.link) + .buttonStyle(.borderless) .font(.callout) } .padding(.top, 8) @@ -471,28 +483,23 @@ struct ExportDialog: View { // MARK: - Footer private var footerView: some View { - HStack { + DialogFooter { + if isExporting { + ProgressView() + .scaleEffect(0.7) + + Text(exportService?.state.currentTable ?? "") + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } actions: { Button("Cancel") { isPresented = false } .disabled(isExporting) - Spacer() - - if isExporting { - HStack(spacing: 8) { - ProgressView() - .scaleEffect(0.7) - - Text(exportService?.state.currentTable ?? "") - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - .frame(maxWidth: 120) - } - } - Button("Export…") { Task { await performExport() @@ -531,7 +538,6 @@ struct ExportDialog: View { .intersection(ExportObjectLoader.loadableKinds) } - /// Count of tables that will actually produce output private var exportableCount: Int { exportableObjects.count } @@ -553,22 +559,6 @@ struct ExportDialog: View { return exportableCount == 0 } - private static let formatDisplayOrder = ["csv", "json", "sql", "xlsx", "md", "html", "xml", "mql"] - - private func formatDescription(for formatId: String) -> String { - switch formatId { - case "csv": return String(localized: "Comma-separated values. Compatible with Excel and most tools.") - case "json": return String(localized: "Structured data format. Ideal for APIs and web applications.") - case "sql": return String(localized: "SQL INSERT statements. Use to recreate data in another database.") - case "xlsx": return String(localized: "Excel spreadsheet with formatting support.") - case "md": return String(localized: "Markdown tables. Paste into a README, an issue or a wiki.") - case "html": return String(localized: "An HTML table. Open in a browser or paste into a page.") - case "xml": return String(localized: "One element per row. Use where a parser expects XML.") - case "mql": return String(localized: "MongoDB query language. Use to import into MongoDB.") - default: return "" - } - } - /// A format change changes which object kinds can be written. Kinds the new format cannot /// write are dropped from the tree, and a format that reaches further than the last load did is /// what makes a reload worth its round trips. @@ -652,8 +642,6 @@ struct ExportDialog: View { ) } - /// Instantly populate the current database from sidebar tables (no network). - /// /// The sidebar lists exactly what the export scope already points at, so the rows carry /// no qualifier. Naming the database here would reach the export data source as a schema /// on the engines that group by schema, which is a different container. @@ -967,13 +955,7 @@ struct ExportDialog: View { } let formatName = currentPlugin.map { type(of: $0).formatDisplayName } ?? config.formatId.uppercased() - if case .streamingQuery = mode { - savePanel.message = String(format: String(localized: "Export query results to %@"), formatName) - } else if isQueryResultsMode { - savePanel.message = String(format: String(localized: "Export %d row(s) to %@"), queryResultsRowCount, formatName) - } else { - savePanel.message = String(format: String(localized: "Export %d table(s) to %@"), exportableCount, formatName) - } + savePanel.message = savePanelMessage(formatName: formatName) let response = await savePanel.presentAsSheet(for: window) guard response == .OK, let url = savePanel.url else { return } @@ -985,6 +967,26 @@ struct ExportDialog: View { } } + /// Counts pick between an explicit singular and plural key. Automatic grammar agreement is a + /// SwiftUI `Text` facility: `String(localized:)` returns `^[table](inflect: true)` verbatim. + private func savePanelMessage(formatName: String) -> String { + if case .streamingQuery = mode { + return String(format: String(localized: "Export query results to %@"), formatName) + } + let count = isQueryResultsMode ? queryResultsRowCount : exportableCount + let template: String + if isQueryResultsMode { + template = count == 1 + ? String(localized: "Export %1$lld row to %2$@") + : String(localized: "Export %1$lld rows to %2$@") + } else { + template = count == 1 + ? String(localized: "Export %1$lld table to %2$@") + : String(localized: "Export %1$lld tables to %2$@") + } + return String(format: template, Int64(count), formatName) + } + /// The database this dialog exports from. Its connection carries the database the sheet /// was opened against, and `resolvedScope` falls back to where the user is browsing when /// that connection has no database of its own. diff --git a/TablePro/Views/Export/ExportObjectRows.swift b/TablePro/Views/Export/ExportObjectRows.swift index ec38179a1..2acf1f316 100644 --- a/TablePro/Views/Export/ExportObjectRows.swift +++ b/TablePro/Views/Export/ExportObjectRows.swift @@ -39,6 +39,14 @@ internal enum ExportObjectKindPresentation { } } + internal static func checkboxValue(for state: TristateCheckbox.State) -> String { + switch state { + case .checked: String(localized: "Selected") + case .unchecked: String(localized: "Not selected") + case .mixed: String(localized: "Partly selected") + } + } + internal static func iconColor(for kind: PluginExportObjectKind) -> Color { switch kind { case .table, .foreignTable: return .gray @@ -64,12 +72,18 @@ internal struct ExportTreeContainerRow: View { internal var body: some View { HStack(spacing: 4) { - TristateCheckbox(state: state, action: toggle) - .frame(width: 18) + TristateCheckbox( + state: state, + accessibilityLabel: title, + accessibilityValue: ExportObjectKindPresentation.checkboxValue(for: state), + action: toggle + ) + .frame(width: 18) Image(systemName: iconName) .foregroundStyle(iconColor) .font(.body) + .accessibilityHidden(true) Text(title) .font(.body) @@ -100,13 +114,17 @@ internal struct ExportTreeObjectRow: View { internal var body: some View { HStack(spacing: 4) { if optionColumns.isEmpty { - Toggle("", isOn: Binding(get: { object.isSelected }, set: setSelected)) + Toggle(object.name, isOn: Binding(get: { object.isSelected }, set: setSelected)) .toggleStyle(.checkbox) .labelsHidden() .frame(width: 18) } else { - TristateCheckbox(state: checkboxState) { - setSelected(!object.isSelected) + TristateCheckbox( + state: checkboxState, + accessibilityLabel: object.name, + accessibilityValue: ExportObjectKindPresentation.checkboxValue(for: checkboxState) + ) { + setSelected(checkboxState != .checked) } .frame(width: 18) } @@ -114,6 +132,7 @@ internal struct ExportTreeObjectRow: View { Image(systemName: ExportObjectKindPresentation.iconName(for: object.kind)) .foregroundStyle(ExportObjectKindPresentation.iconColor(for: object.kind)) .font(.body) + .accessibilityHidden(true) Text(object.name) .font(.body) @@ -155,6 +174,10 @@ internal struct ExportTreeObjectRow: View { .foregroundStyle(object.rowScope.isUnrestricted ? Color.secondary : Color.accentColor) } .buttonStyle(.borderless) + .accessibilityLabel(String(localized: "Row scope")) + .accessibilityValue(object.rowScope.isUnrestricted + ? String(localized: "Every row and column") + : object.rowScope.summary) .help(object.rowScope.isUnrestricted ? String(localized: "Narrow the rows and columns to export") : object.rowScope.summary) @@ -182,7 +205,6 @@ internal struct ExportTreeObjectRow: View { .toggleStyle(.checkbox) .labelsHidden() .disabled(!object.isSelected) - .opacity(object.isSelected ? 1.0 : 0.4) .frame(width: column.width, alignment: .center) } else { Color.clear.frame(width: column.width, height: 1) diff --git a/TablePro/Views/Export/ExportObjectTreeView.swift b/TablePro/Views/Export/ExportObjectTreeView.swift index 05ecd5d6d..ef2f463d1 100644 --- a/TablePro/Views/Export/ExportObjectTreeView.swift +++ b/TablePro/Views/Export/ExportObjectTreeView.swift @@ -27,17 +27,22 @@ internal struct ExportObjectTreeView: NSViewRepresentable { } internal func makeNSView(context: Context) -> NSScrollView { - let outlineView = NSOutlineView() + let outlineView = ExportOutlineView() outlineView.headerView = nil outlineView.style = .plain - outlineView.rowSizeStyle = .default - outlineView.allowsMultipleSelection = false + outlineView.rowSizeStyle = .custom + outlineView.rowHeight = 24 + outlineView.allowsMultipleSelection = true outlineView.allowsEmptySelection = true outlineView.usesAlternatingRowBackgroundColors = true outlineView.autosaveExpandedItems = false outlineView.indentationPerLevel = 14 outlineView.dataSource = context.coordinator outlineView.delegate = context.coordinator + outlineView.toggleSelectedRows = { [weak coordinator = context.coordinator] in + coordinator?.toggleSelectedRows() + } + outlineView.setAccessibilityLabel(String(localized: "Objects to export")) let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("ExportObjectColumn")) column.resizingMask = .autoresizingMask @@ -68,10 +73,15 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou private weak var outlineView: NSOutlineView? private var roots: [ExportOutlineNode] = [] + private var nodesByIdentity: [String: ExportOutlineNode] = [:] private var databases: [ExportDatabaseItem] = [] private var shapeFingerprint = "" private var formatId = "" + /// The rows the coordinator's own last mutation touched. Nil means the change came from + /// outside it, a profile or a preselection, and nothing local knows which rows it moved. + private var pendingReloadIdentities: Set? + /// Every node the user has collapsed, by its stable identity, so a rebuild restores what they /// chose rather than reopening the whole tree. private var collapsedIdentities: Set = [] @@ -99,21 +109,77 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou restoreExpansion() return } + let isFirstBuild = shapeFingerprint.isEmpty shapeFingerprint = fingerprint roots = ExportOutlineTreeBuilder.build(from: databases) + indexNodes() + pendingReloadIdentities = nil + if isFirstBuild { seedCollapsedFromModel() } outlineView?.reloadData() restoreExpansion() } /// A checkbox toggle leaves the tree's shape alone, so the rows are redrawn in place. Rebuilding /// would collapse and re-expand every group under the user's pointer. + /// + /// Only the rows the toggle actually changed are redrawn. Reloading the whole tree rebuilt one + /// `AnyView` and wrote one `NSHostingView.rootView` per object in the database, so a click in a + /// schema of several thousand tables paid for all of them. private func reloadRowContent() { guard let outlineView else { return } - let rows = IndexSet(integersIn: 0 ..< outlineView.numberOfRows) + 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)) + return + } + pendingReloadIdentities = nil + var rows = IndexSet() + for identity in identities { + guard let node = nodesByIdentity[identity] else { continue } + let row = outlineView.row(forItem: node) + guard row >= 0 else { continue } + rows.insert(row) + } guard !rows.isEmpty else { return } outlineView.reloadData(forRowIndexes: rows, columnIndexes: IndexSet(integer: 0)) } + /// What the loader already decided. It opens the database the dialog was scoped to and any + /// container the preselection names, and leaves the rest closed; without this every database + /// on the server opened at once. Only the first build reads it, so a later reload never + /// reopens something the user has since collapsed. + private func seedCollapsedFromModel() { + for database in databases where !database.isExpanded { + collapsedIdentities.insert("db:\(database.id.uuidString)") + } + } + + private func indexNodes() { + nodesByIdentity = [:] + var stack = roots + while let node = stack.popLast() { + nodesByIdentity[node.identity] = node + stack.append(contentsOf: node.children) + } + } + + /// The rows a change to these objects can redraw: each object's own row, and every container + /// above it, whose tri-state checkbox is a function of what it holds. + private func markForReload(objectIDs: Set) { + var identities = pendingReloadIdentities ?? [] + for database in databases { + let touched = database.objects.filter { objectIDs.contains($0.id) } + guard !touched.isEmpty else { continue } + identities.insert("db:\(database.id.uuidString)") + for object in touched { + identities.insert("obj:\(database.id.uuidString):\(object.id.uuidString)") + identities.insert("group:\(database.id.uuidString):\(object.kind.rawValue)") + } + } + pendingReloadIdentities = identities + } + private func restoreExpansion() { guard let outlineView else { return } for root in roots { @@ -150,8 +216,31 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou return !node.isLeaf } + /// Rows are selectable so the arrow keys reach them. Selection is how an `NSOutlineView` is + /// navigated: refusing it leaves Space with nothing to toggle, Left and Right with nothing to + /// collapse, and VoiceOver with no way into the tree at all. internal func outlineView(_ outlineView: NSOutlineView, shouldSelectItem item: Any) -> Bool { - false + true + } + + /// 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. + internal func toggleSelectedRows() { + guard let outlineView else { return } + let nodes = outlineView.selectedRowIndexes.compactMap { + outlineView.item(atRow: $0) as? ExportOutlineNode + } + guard !nodes.isEmpty else { return } + for node in nodes { + switch node.kind { + case .database, .group: + toggleContainer(node) + 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) + } + } } internal func outlineViewItemDidExpand(_ notification: Notification) { @@ -179,7 +268,18 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou return cell } + /// Every row carries the identity of the object it draws. + /// + /// `NSOutlineView` hands one recycled cell to a different object as rows scroll, and the cell + /// swaps its hosting view's root rather than rebuilding it. SwiftUI ties `@State` to view + /// identity, so without an id of its own a row's loaded column list and half-edited row scope + /// reconcile onto whatever object inherits the cell: opening the scope popover on the second + /// table would offer the first one's columns and write them into the second one's SELECT. private func rowContent(for node: ExportOutlineNode) -> AnyView { + AnyView(rowBody(for: node).id(node.identity)) + } + + private func rowBody(for node: ExportOutlineNode) -> AnyView { switch node.kind { case .database(let databaseID): return AnyView( @@ -274,7 +374,7 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou let items = objects(under: node) let turningOn = items.contains { !$0.isSelected } let ids = Set(items.map(\.id)) - mutateDatabases { databases in + mutateDatabases(touching: ids) { databases in for databaseIndex in databases.indices { for objectIndex in databases[databaseIndex].objects.indices where ids.contains(databases[databaseIndex].objects[objectIndex].id) { @@ -287,7 +387,7 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou } private func setSelection(_ isSelected: Bool, databaseID: UUID, objectID: UUID) { - mutateDatabases { databases in + mutateDatabases(touching: [objectID]) { databases in guard let databaseIndex = databases.firstIndex(where: { $0.id == databaseID }), let objectIndex = databases[databaseIndex].objects.firstIndex(where: { $0.id == objectID }) else { return } @@ -304,7 +404,7 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou guard !columns.isEmpty else { return } let defaults = defaultOptionValues let supports = supportsOption - mutateDatabases { databases in + mutateDatabases(touching: ids) { databases in for databaseIndex in databases.indices { for objectIndex in databases[databaseIndex].objects.indices where ids.contains(databases[databaseIndex].objects[objectIndex].id) { @@ -321,7 +421,7 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou } private func setOption(_ index: Int, to value: Bool, databaseID: UUID, objectID: UUID) { - mutateDatabases { databases in + 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) @@ -333,7 +433,7 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou } private func setRowScope(_ scope: PluginExportRowScope, databaseID: UUID, objectID: UUID) { - mutateDatabases { databases in + mutateDatabases(touching: [objectID]) { databases in guard let databaseIndex = databases.firstIndex(where: { $0.id == databaseID }), let objectIndex = databases[databaseIndex].objects.firstIndex(where: { $0.id == objectID }) else { return } @@ -341,14 +441,33 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou } } - private func mutateDatabases(_ change: (inout [ExportDatabaseItem]) -> Void) { + private func mutateDatabases( + touching ids: Set, + _ change: (inout [ExportDatabaseItem]) -> Void + ) { var updated = databases change(&updated) databases = updated + markForReload(objectIDs: ids) owner.databaseItems = updated } } +/// Adds the one key an `NSOutlineView` of checkboxes needs and does not get for free. Arrow keys, +/// Left and Right to collapse and expand, Home and End and type-select are all AppKit's own once +/// the rows are selectable. +internal final class ExportOutlineView: NSOutlineView { + internal var toggleSelectedRows: (() -> Void)? + + override internal func keyDown(with event: NSEvent) { + guard event.charactersIgnoringModifiers == " ", !selectedRowIndexes.isEmpty else { + super.keyDown(with: event) + return + } + toggleSelectedRows?() + } +} + /// The cell keeps one hosting view for the life of the row and only swaps its root, because /// rebuilding the host on every reload loses the SwiftUI state the checkboxes animate from. internal final class ExportObjectCellView: NSTableCellView { diff --git a/TablePro/Views/Export/ExportProgressView.swift b/TablePro/Views/Export/ExportProgressView.swift index addcf5051..e63028ff8 100644 --- a/TablePro/Views/Export/ExportProgressView.swift +++ b/TablePro/Views/Export/ExportProgressView.swift @@ -2,15 +2,18 @@ // ExportProgressView.swift // TablePro // -// Progress dialog shown during table export. -// Displays table name, row progress, progress bar, and stop button. -// import SwiftUI -/// Progress dialog shown during export operation +/// What the export is working on and how far through it is. +/// +/// A determinate bar is drawn only when there is a total to be a fraction of. A streaming query has +/// no row count until it ends, so the bar used to sit at zero for the whole run beside a label +/// reading "18,204/0 rows", which says the export is stuck rather than that its size is unknown. struct ExportProgressView: View { - let tableName: String + /// What is being exported, named by the caller. Deriving it from the table index reads + /// " (0/1)" on the streaming path, where no table is ever current. + let subject: String let tableIndex: Int let totalTables: Int let processedRows: Int @@ -20,40 +23,53 @@ struct ExportProgressView: View { @State private var showStopConfirmation = false + private var hasRowTotal: Bool { totalRows > 0 } + + private var title: String { + totalTables > 1 + ? String(localized: "Export multiple tables") + : String(localized: "Export table") + } + + private var subjectLabel: String { + guard totalTables > 1 else { return subject } + return String( + format: String(localized: "%1$@ (%2$lld of %3$lld)"), + subject, Int64(tableIndex), Int64(totalTables)) + } + var body: some View { VStack(spacing: 20) { - Text(totalTables > 1 - ? String(localized: "Export multiple tables") - : String(localized: "Export table")) + Text(title) .font(.title3.weight(.semibold)) VStack(spacing: 8) { HStack { - if !statusMessage.isEmpty { - Text(statusMessage) - .font(.body) - .foregroundStyle(.secondary) - } else { - Text("\(tableName) (\(tableIndex)/\(totalTables))") + if statusMessage.isEmpty { + Text(subjectLabel) .font(.body) .lineLimit(1) .truncationMode(.middle) + } else { + Text(statusMessage) + .font(.body) + .foregroundStyle(.secondary) } Spacer() if statusMessage.isEmpty { - Text("\(processedRows.formatted())/\(totalRows.formatted()) rows") + Text(rowCountLabel) .font(.system(.body, design: .monospaced)) .foregroundStyle(.secondary) } } - if !statusMessage.isEmpty { - ProgressView() + if statusMessage.isEmpty, hasRowTotal { + ProgressView(value: progressValue) .progressViewStyle(.linear) } else { - ProgressView(value: progressValue) + ProgressView() .progressViewStyle(.linear) } } @@ -61,10 +77,9 @@ struct ExportProgressView: View { Button("Stop") { showStopConfirmation = true } - .frame(width: 80) } .padding(24) - .frame(width: 400) + .frame(minWidth: 400) .background(Color(nsColor: .windowBackgroundColor)) .alert(String(localized: "Stop Export?"), isPresented: $showStopConfirmation) { Button(String(localized: "Continue"), role: .cancel) {} @@ -74,9 +89,18 @@ struct ExportProgressView: View { } } + private var rowCountLabel: String { + guard hasRowTotal else { + return String(format: String(localized: "%@ rows"), processedRows.formatted()) + } + return String( + format: String(localized: "%1$@/%2$@ rows"), + processedRows.formatted(), totalRows.formatted()) + } + private var progressValue: Double { guard totalRows > 0 else { return 0 } - return Double(processedRows) / Double(totalRows) + return min(1.0, Double(processedRows) / Double(totalRows)) } } @@ -84,7 +108,7 @@ struct ExportProgressView: View { #Preview { ExportProgressView( - tableName: "users", + subject: "users", tableIndex: 1, totalTables: 3, processedRows: 95_500, diff --git a/TablePro/Views/Export/ExportRowScopeEditor.swift b/TablePro/Views/Export/ExportRowScopeEditor.swift index 361862713..0da8285b8 100644 --- a/TablePro/Views/Export/ExportRowScopeEditor.swift +++ b/TablePro/Views/Export/ExportRowScopeEditor.swift @@ -54,6 +54,10 @@ internal struct ExportRowScopeEditor: View { TextField("All rows", text: $rowLimitText) .textFieldStyle(.roundedBorder) .frame(width: 120) + .onChange(of: rowLimitText) { _, entered in + let digits = entered.filter(\.isWholeNumber) + if digits != entered { rowLimitText = digits } + } } if !availableColumns.isEmpty { @@ -63,11 +67,10 @@ internal struct ExportRowScopeEditor: View { .font(.subheadline) .foregroundStyle(.secondary) Spacer() - Button(selectedColumns.isEmpty ? "Select None" : "Select All") { - selectedColumns = selectedColumns.isEmpty ? [] : Set(availableColumns) - } - .buttonStyle(.link) - .font(.caption) + Button("Select All") { selectedColumns = [] } + .buttonStyle(.borderless) + .font(.caption) + .disabled(selectedColumns.isEmpty) } ScrollView { VStack(alignment: .leading, spacing: 2) { @@ -130,6 +133,13 @@ internal struct ExportRowScopeEditor: View { private func commit() { let trimmedLimit = rowLimitText.trimmingCharacters(in: .whitespaces) let limit = Int(trimmedLimit).flatMap { $0 > 0 ? $0 : nil } + /// The column list is read on demand and a failed read comes back empty, which cannot be + /// told apart here from a table with no columns. Deriving the set from it either way threw + /// away a column subset the user had already saved. + guard !availableColumns.isEmpty else { + scope = PluginExportRowScope(filter: filter, rowLimit: limit, columns: scope.columns) + return + } scope = PluginExportRowScope( filter: filter, rowLimit: limit, diff --git a/TablePro/Views/Export/TableTransferMappingEditor.swift b/TablePro/Views/Export/TableTransferMappingEditor.swift index fbeec15fb..cabf89e64 100644 --- a/TablePro/Views/Export/TableTransferMappingEditor.swift +++ b/TablePro/Views/Export/TableTransferMappingEditor.swift @@ -48,7 +48,8 @@ internal struct TableTransferMappingEditor: View { .truncationMode(.middle) .frame(width: 130, alignment: .leading) - Picker("", selection: binding(for: column)) { + Picker(String(format: String(localized: "Destination for %@"), column), + selection: binding(for: column)) { Text("Skip").tag(String?.none) ForEach(destinationColumns, id: \.self) { target in Text(target).tag(String?.some(target)) diff --git a/TablePro/Views/Export/TableTransferSheet.swift b/TablePro/Views/Export/TableTransferSheet.swift index fb1713472..87060c3cf 100644 --- a/TablePro/Views/Export/TableTransferSheet.swift +++ b/TablePro/Views/Export/TableTransferSheet.swift @@ -70,7 +70,7 @@ struct TableTransferSheet: View { footer } - .frame(width: 460, height: 460) + .frame(minWidth: 460, minHeight: 420, idealHeight: 460, maxHeight: .infinity) .background(Color(nsColor: .windowBackgroundColor)) .background { WindowAccessor { window in hostWindow = window } @@ -164,8 +164,18 @@ struct TableTransferSheet: View { } private var footer: some View { - HStack { - Button("Cancel") { + DialogFooter { + if isRunning { + ProgressView() + .scaleEffect(0.7) + Text(progressLabel) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } actions: { + Button(isRunning ? String(localized: "Stop") : String(localized: "Cancel")) { if isRunning { service.cancel() } else { @@ -173,19 +183,6 @@ struct TableTransferSheet: View { } } - Spacer() - - if isRunning { - HStack(spacing: 8) { - ProgressView() - .scaleEffect(0.7) - Text(progressLabel) - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - Button("Transfer") { Task { await runTransfer() } } @@ -217,7 +214,7 @@ struct TableTransferSheet: View { .scaleEffect(0.5) .frame(width: 16) } else if destinationColumns[table] == nil { - Text("no such table") + Text("No such table") .font(.caption) .foregroundStyle(.red) } else { @@ -252,7 +249,7 @@ struct TableTransferSheet: View { } private func mappingLabel(_ match: TableColumnMatcher.Match) -> String { - guard !match.isEmpty else { return String(localized: "no columns match") } + guard !match.isEmpty else { return String(localized: "No columns match") } guard match.unmatchedSource.isEmpty else { return String( format: String(localized: "%1$lld mapped, %2$lld skipped"), @@ -340,20 +337,24 @@ struct TableTransferSheet: View { @MainActor private func confirmIfNeeded(destination: DatabaseConnection) async -> Bool { guard deleteExistingRows || destination.safeModeLevel.requiresConfirmation else { return true } - let alert = NSAlert() - alert.alertStyle = deleteExistingRows ? .critical : .warning - alert.messageText = String( - format: String(localized: "Transfer %1$lld table(s) into %2$@?"), - Int64(selectedTables.count), - destination.name) - alert.informativeText = deleteExistingRows - ? String(localized: "Every row in each destination table is deleted first. This cannot be undone.") - : String(localized: "Rows are written into tables that already exist on the destination.") - alert.addButton(withTitle: String(localized: "Transfer")) - alert.addButton(withTitle: String(localized: "Cancel")) - - guard let hostWindow else { return alert.runModal() == .alertFirstButtonReturn } - return await alert.beginSheetModal(for: hostWindow) == .alertFirstButtonReturn + let template = selectedTables.count == 1 + ? String(localized: "Transfer %1$lld table into %2$@?") + : String(localized: "Transfer %1$lld tables into %2$@?") + let title = String(format: template, Int64(selectedTables.count), destination.name) + guard deleteExistingRows else { + return await AlertHelper.confirm( + title: title, + message: String(localized: "Rows are written into tables that already exist on the destination."), + confirmButton: String(localized: "Transfer"), + window: hostWindow + ) + } + return await AlertHelper.confirmCritical( + title: title, + message: String(localized: "Every row in each destination table is deleted first. This cannot be undone."), + confirmButton: String(localized: "Transfer"), + window: hostWindow + ) } /// Reads the columns on both sides for every ticked table, so the sheet can say what will map @@ -370,16 +371,10 @@ struct TableTransferSheet: View { sourceColumns[table] = await columns( of: table, on: sourceScope, schema: nil) } - guard let destinationDriver = DatabaseManager.shared.driver(for: destinationConnection.id), - let pluginDriver = (destinationDriver as? PluginDriverAdapter)?.schemaPluginDriver - else { return } + guard let destinationScope else { return } for table in tables where destinationColumns[table] == nil { - do { - let found = try await pluginDriver.fetchColumns(table: table, schema: nil).map(\.name) - destinationColumns[table] = found.isEmpty ? nil : found - } catch { - Self.logger.warning("Destination has no \(table, privacy: .public)") - } + let found = await columns(of: table, on: destinationScope, schema: nil) + destinationColumns[table] = found.isEmpty ? nil : found } } @@ -404,12 +399,23 @@ struct TableTransferSheet: View { ) ?? DatabaseScope(connectionId: sourceConnection.id, database: sourceConnection.database, schema: nil) } + /// The database the user picked, not wherever the destination connection was last parked. Both + /// the column read and the write go through this: the picker used to bind to a value nothing + /// read, so choosing a database changed the label and sent the rows somewhere else. + private var destinationScope: DatabaseScope? { + guard let destinationConnection else { return nil } + let database = destinationDatabase.isEmpty + ? destinationConnection.database + : destinationDatabase + return DatabaseScope( + connectionId: destinationConnection.id, database: database, schema: nil) + } + // MARK: - Running @MainActor private func runTransfer() async { - guard let destinationConnection, - let destinationDriver = DatabaseManager.shared.driver(for: destinationConnection.id) else { + guard let destinationConnection, let destinationScope else { errorMessage = TableTransferError.notConnected(connectionName: "").localizedDescription return } @@ -422,6 +428,7 @@ struct TableTransferSheet: View { guard await confirmIfNeeded(destination: destinationConnection) else { return } errorMessage = nil + service.prepareForRun() isRunning = true defer { isRunning = false } @@ -454,21 +461,73 @@ struct TableTransferSheet: View { wrapInTransaction: wrapInTransaction ) + let startedAt = ContinuousClock.Instant.now + let destinationRoute = DatabaseManager.shared.executionRoute(for: destinationScope) do { try await DatabaseManager.shared.withMetadataDriver( scope: sourceScope, workload: .bulk ) { sourceDriver in - try await service.transfer( - request: request, - sourceDriver: sourceDriver, - destinationDriver: destinationDriver - ) + try await DatabaseManager.shared.withScopedDriver( + scope: destinationScope, + route: destinationRoute, + workload: .bulk, + cancellation: .untracked + ) { destinationDriver in + try await service.transfer( + request: request, + sourceDriver: sourceDriver, + destinationDriver: destinationDriver + ) + } } - isPresented = false + reportFinished( + .succeeded(OperationSummary(rowsAffected: service.state.transferredRows)), + destination: destinationConnection, + since: startedAt) + presentResult(destination: destinationConnection) } catch is PluginImportCancellationError { errorMessage = nil + reportFinished(.cancelled, destination: destinationConnection, since: startedAt) } catch { errorMessage = error.localizedDescription + reportFinished( + .failed(reason: error.localizedDescription), + destination: destinationConnection, + since: startedAt) } } + + /// A transfer that finished used to close its sheet and say nothing, so a run that skipped rows + /// looked identical to a clean one. The alert closes the sheet once the user has read it. + @MainActor + private func presentResult(destination: DatabaseConnection) { + TransferResultAlert.presentTransferSuccess( + tableCount: selectedTables.count, + rowCount: service.state.transferredRows, + destinationName: destination.name, + warnings: service.state.warnings, + window: hostWindow + ) { + isPresented = false + } + } + + @MainActor + private func reportFinished( + _ outcome: OperationOutcome, + destination: DatabaseConnection, + since startedAt: ContinuousClock.Instant + ) { + OperationCompletionReporter.shared.report( + OperationCompletion( + kind: .dataImport, + owner: .connection(destination.id), + connectionId: destination.id, + connectionName: destination.name, + databaseName: destinationScope?.database, + elapsed: startedAt.duration(to: .now), + outcome: outcome + ) + ) + } } diff --git a/TablePro/Views/Import/ImportDialog.swift b/TablePro/Views/Import/ImportDialog.swift index b01874154..d9c7fac35 100644 --- a/TablePro/Views/Import/ImportDialog.swift +++ b/TablePro/Views/Import/ImportDialog.swift @@ -190,7 +190,6 @@ struct ImportDialog: View { await selectFile() } } - .buttonStyle(.link) .font(.callout) } @@ -227,12 +226,13 @@ struct ImportDialog: View { .font(.body) .frame(width: 80, alignment: .leading) - Picker("", selection: $selectedFormatId) { + Picker(String(localized: "Format"), selection: $selectedFormatId) { ForEach(availableFormats.map { (id: type(of: $0).formatId, name: type(of: $0).formatDisplayName) }, id: \.id) { item in Text(item.name).tag(item.id) } } .pickerStyle(.menu) + .labelsHidden() .frame(width: 120) Spacer() @@ -267,23 +267,23 @@ struct ImportDialog: View { Button("Reset to Defaults") { resetOptionsToDefaults() } - .buttonStyle(.link) + .buttonStyle(.borderless) .font(.callout) } VStack(alignment: .leading, spacing: 12) { - // Encoding picker (always shown, independent of plugin) HStack(spacing: 8) { Text("Encoding:") .font(.body) .frame(width: 80, alignment: .leading) - Picker("", selection: $selectedEncoding) { + Picker(String(localized: "Encoding"), selection: $selectedEncoding) { ForEach(ImportEncoding.allCases) { enc in Text(enc.rawValue).tag(enc) } } .pickerStyle(.menu) + .labelsHidden() .frame(width: 120) .onChange(of: selectedEncoding) { _, _ in loadFileTask?.cancel() @@ -492,6 +492,10 @@ struct ImportDialog: View { } catch is PluginImportCancellationError { await MainActor.run { showProgressDialog = false + TransferResultAlert.presentImportCancelled( + executedStatements: service.state.processedStatements, + window: hostWindow + ) {} } } catch { await MainActor.run { diff --git a/TablePro/Views/Import/ImportProgressView.swift b/TablePro/Views/Import/ImportProgressView.swift index 2f1bb31dc..d98226abf 100644 --- a/TablePro/Views/Import/ImportProgressView.swift +++ b/TablePro/Views/Import/ImportProgressView.swift @@ -2,15 +2,21 @@ // ImportProgressView.swift // TablePro // -// Progress dialog shown during import. -// import SwiftUI +/// How far an import has got, and the one way to stop it. +/// +/// Stopping asks first, the way the export and backup sheets do. An import writes rows, so an +/// accidental press is the expensive one of the three: statements already run stay committed. struct ImportProgressView: View { let service: ImportService let onStop: () -> Void + @State private var showStopConfirmation = false + + private var hasEstimate: Bool { service.state.estimatedTotalStatements > 0 } + var body: some View { VStack(spacing: 20) { Text("Importing…") @@ -18,39 +24,47 @@ struct ImportProgressView: View { VStack(spacing: 8) { HStack { - if !service.state.statusMessage.isEmpty { - Text(service.state.statusMessage) + if service.state.statusMessage.isEmpty { + Text("Executed \(service.state.processedStatements) ^[statement](inflect: true)") .font(.body) - .foregroundStyle(.secondary) } else { - Text("Executed \(service.state.processedStatements) statements") + Text(service.state.statusMessage) .font(.body) - - Spacer() + .foregroundStyle(.secondary) } + + Spacer() } - if !service.state.statusMessage.isEmpty { - ProgressView() + if service.state.statusMessage.isEmpty, hasEstimate { + ProgressView(value: progressValue) .progressViewStyle(.linear) } else { - ProgressView(value: progressValue) + ProgressView() .progressViewStyle(.linear) } } Button("Stop") { - onStop() + showStopConfirmation = true } - .frame(width: 80) } .padding(24) - .frame(width: 500) + .frame(minWidth: 500) .background(Color(nsColor: .windowBackgroundColor)) + .onExitCommand { showStopConfirmation = true } + .alert(String(localized: "Stop Import?"), isPresented: $showStopConfirmation) { + Button(String(localized: "Continue"), role: .cancel) {} + Button(String(localized: "Stop"), role: .destructive) { onStop() } + } message: { + Text("Statements already executed stay committed.") + } } private var progressValue: Double { guard service.state.estimatedTotalStatements > 0 else { return 0 } - return min(1.0, Double(service.state.processedStatements) / Double(service.state.estimatedTotalStatements)) + return min( + 1.0, + Double(service.state.processedStatements) / Double(service.state.estimatedTotalStatements)) } } diff --git a/TablePro/Views/Import/RowImportSheet.swift b/TablePro/Views/Import/RowImportSheet.swift index 131ab5b52..2a5aeaf2c 100644 --- a/TablePro/Views/Import/RowImportSheet.swift +++ b/TablePro/Views/Import/RowImportSheet.swift @@ -56,6 +56,12 @@ struct RowImportSheet: View { @State private var isLoadingContext = false @State private var loadError: String? + /// The plugin's own options are persistent and shared, and this sheet edits them in place. + /// Without a snapshot, Cancel kept every change, so `Delete existing rows` stayed armed for + /// the next import from anywhere in the app. + @State private var settingsSnapshot: PluginSettingsSnapshot? + @State private var importSucceeded = false + @State private var importService: ImportService? @State private var importResult: PluginImportResult? @State private var importError: (any Error)? @@ -97,13 +103,15 @@ struct RowImportSheet: View { footerView .padding() } - .frame(width: 720, height: 640) + .frame(minWidth: 720, minHeight: 560, idealHeight: 640, maxHeight: .infinity) .background { WindowAccessor { window in hostWindow = window } } .task { + settingsSnapshot = PluginSettingsSnapshot( + plugins: [currentPlugin as? any SettablePluginDiscoverable].compactMap { $0 }) await loadTables() await loadNewColumns() } @@ -116,7 +124,11 @@ struct RowImportSheet: View { .onChange(of: currentPlugin?.fieldDetectionSignature) { _, _ in Task { await redetectFields() } } - .onDisappear { importTask?.cancel() } + .onDisappear { + importTask?.cancel() + if !importSucceeded { settingsSnapshot?.restore() } + settingsSnapshot = nil + } .sheet(isPresented: $showProgressDialog) { if let service = importService { ImportProgressView(service: service) { service.cancelImport() } @@ -170,7 +182,7 @@ struct RowImportSheet: View { GridRow { Text("Destination:") .gridColumnAlignment(.trailing) - Picker("", selection: $destination) { + Picker(String(localized: "Destination"), selection: $destination) { Text("Existing table").tag(Destination.existingTable) Text("New table").tag(Destination.newTable) } @@ -182,7 +194,7 @@ struct RowImportSheet: View { if destination == .existingTable { GridRow { Text("Import into:") - Picker("", selection: $selectedTargetTable) { + Picker(String(localized: "Import into"), selection: $selectedTargetTable) { Text("Select a table…").tag(String?.none) ForEach(availableTables, id: \.id) { table in Text(table.name).tag(String?.some(table.name)) @@ -237,24 +249,57 @@ struct RowImportSheet: View { @ViewBuilder private var contentArea: some View { - switch destination { - case .existingTable: - if selectedTargetTable == nil { - placeholder("Choose a destination table to map fields.") - } else if mappings.isEmpty { - placeholder(loadError ?? "No fields found in the file.") - } else { - mappingTable + if let loadError { + unreadableFile(reason: loadError) + } else { + switch destination { + case .existingTable: + if selectedTargetTable == nil { + placeholder("Choose a destination table to map fields.") + } else if mappings.isEmpty { + placeholder("No fields found in the file.") + } else { + mappingTable + } + case .newTable: + if newColumns.isEmpty { + placeholder("No columns found in the file.") + } else { + newColumnsTable + } } - case .newTable: - if newColumns.isEmpty { - placeholder(loadError ?? "No columns found in the file.") - } else { - newColumnsTable + } + } + + /// A file the plugin could not read is a failure, not an empty result. Showing the parser's + /// message as grey placeholder text left the sheet with nothing to press but Cancel. + private func unreadableFile(reason: String) -> some View { + ContentUnavailableView { + Label(String(localized: "Cannot read this file"), systemImage: "exclamationmark.triangle") + } description: { + Text(reason) + } actions: { + Button(String(localized: "Try Again")) { + Task { await retryLoad() } } } } + @MainActor + private func retryLoad() async { + loadError = nil + newColumnsLoaded = false + newColumns = [] + mappings = [] + switch destination { + case .newTable: + await loadNewColumns() + case .existingTable: + guard let table = selectedTargetTable else { return } + await loadExistingContext(table: table) + } + } + private func placeholder(_ message: String) -> some View { VStack { Spacer() @@ -301,8 +346,9 @@ struct RowImportSheet: View { private func mappingRow(_ row: FieldMapping) -> some View { HStack(spacing: 12) { - Toggle("", isOn: mappingBinding(row).include) + Toggle(row.field.name, isOn: mappingBinding(row).include) .labelsHidden() + .accessibilityLabel(Text(String(format: String(localized: "Import %@"), row.field.name))) .frame(width: 16) VStack(alignment: .leading, spacing: 1) { Text(row.field.name).lineLimit(1) @@ -311,7 +357,8 @@ struct RowImportSheet: View { } } .frame(maxWidth: .infinity, alignment: .leading) - Picker("", selection: mappingBinding(row).targetColumn) { + Picker(String(format: String(localized: "Column for %@"), row.field.name), + selection: mappingBinding(row).targetColumn) { Text("Skip").tag(String?.none) ForEach(targetColumns, id: \.self) { column in Text(column).tag(String?.some(column)) @@ -326,9 +373,10 @@ struct RowImportSheet: View { private var newColumnsTable: some View { VStack(spacing: 0) { HStack(spacing: 10) { - Toggle("", isOn: allColumnsIncluded) + Toggle(String(localized: "Create all columns"), isOn: allColumnsIncluded) .labelsHidden() .help(String(localized: "Create all columns")) + .accessibilityLabel(Text("Create all columns")) .frame(width: 16) Text("Column") .font(.caption) @@ -369,40 +417,37 @@ struct RowImportSheet: View { private func newColumnRow(_ row: NewColumn) -> some View { HStack(spacing: 10) { - Toggle("", isOn: columnBinding(row).include) + Toggle(row.name, isOn: columnBinding(row).include) .labelsHidden() + .accessibilityLabel(Text(String(format: String(localized: "Create %@"), row.name))) .frame(width: 16) TextField("name", text: columnBinding(row).name) .textFieldStyle(.roundedBorder) .frame(width: 150) .disabled(!row.include) - Menu { + Picker(String(localized: "Type"), selection: typeBinding(row)) { ForEach(typeOptions(including: row.type), id: \.self) { type in - Button { - columnBinding(row).type.wrappedValue = type - } label: { - if type.caseInsensitiveCompare(row.type) == .orderedSame { - Label(type, systemImage: "checkmark") - } else { - Text(type) - } - } + Text(type).tag(type) } - } label: { - Text(row.type) - .frame(maxWidth: .infinity, alignment: .leading) } + .pickerStyle(.menu) + .labelsHidden() + .accessibilityLabel(Text(String(format: String(localized: "Type of %@"), row.name))) .frame(width: 150) .disabled(!row.include) - Toggle("", isOn: columnBinding(row).isPrimaryKey) + Toggle(String(localized: "Primary key"), isOn: columnBinding(row).isPrimaryKey) .labelsHidden() + .accessibilityLabel(Text(String(format: String(localized: "%@ is a primary key"), row.name))) .frame(minWidth: 30) .disabled(!row.include) - Toggle("", isOn: columnBinding(row).isNullable) + Toggle(String(localized: "Nullable"), isOn: columnBinding(row).isNullable) .labelsHidden() + .accessibilityLabel(Text(String(format: String(localized: "%@ accepts null"), row.name))) .frame(minWidth: 30) .disabled(!row.include) - TextField("", text: columnBinding(row).defaultValue) + TextField(String(localized: "Default value"), text: columnBinding(row).defaultValue) + .labelsHidden() + .accessibilityLabel(Text(String(format: String(localized: "Default for %@"), row.name))) .textFieldStyle(.roundedBorder) .frame(maxWidth: .infinity) .disabled(!row.include) @@ -468,6 +513,17 @@ struct RowImportSheet: View { .sorted() } + /// The selection has to be one of the options by exact spelling or the menu draws blank, and + /// `typeOptions` suppresses its insert on a case-insensitive match. The getter resolves through + /// the same comparison so a differently-cased stored type still selects its own row. + private func typeBinding(_ row: NewColumn) -> Binding { + let options = typeOptions(including: row.type) + return Binding( + get: { options.first { $0.caseInsensitiveCompare(row.type) == .orderedSame } ?? row.type }, + set: { columnBinding(row).type.wrappedValue = $0 } + ) + } + private func typeOptions(including current: String) -> [String] { var types = dialectTypes if !types.contains(where: { $0.caseInsensitiveCompare(current) == .orderedSame }) { @@ -505,13 +561,27 @@ struct RowImportSheet: View { } } + /// `detectSourceFields` is synchronous and reads the file: the XLSX plugin materialises the + /// whole workbook, the CSV one reads a megabyte. Every state write stays on the main actor, + /// only the parse leaves it. + nonisolated private static func detectFields( + plugin: any ImportFormatPlugin, + at url: URL, + targetTable: String? + ) async throws -> [PluginImportField] { + try await Task.detached { + try plugin.detectSourceFields(at: url, targetTable: targetTable) + }.value + } + @MainActor private func loadNewColumns() async { guard !newColumnsLoaded, let plugin = currentPlugin else { return } isLoadingContext = true + loadError = nil defer { isLoadingContext = false } do { - let fields = try plugin.detectSourceFields(at: fileURL, targetTable: nil) + let fields = try await Self.detectFields(plugin: plugin, at: fileURL, targetTable: nil) newColumns = fields.map { field in NewColumn( field: field, @@ -539,7 +609,7 @@ struct RowImportSheet: View { defer { isLoadingContext = false } do { let columns = try await driver.fetchColumns(table: table).map(\.name) - let fields = try plugin.detectSourceFields(at: fileURL, targetTable: table) + let fields = try await Self.detectFields(plugin: plugin, at: fileURL, targetTable: table) targetColumns = columns mappings = fields.map { field in let match = columns.first { $0.caseInsensitiveCompare(field.name) == .orderedSame } @@ -653,11 +723,18 @@ struct RowImportSheet: View { ) await MainActor.run { showProgressDialog = false + importSucceeded = true importResult = result showSuccessDialog = true } } catch is PluginImportCancellationError { - await MainActor.run { showProgressDialog = false } + await MainActor.run { + showProgressDialog = false + TransferResultAlert.presentImportCancelled( + executedStatements: service.state.processedStatements, + window: hostWindow + ) {} + } } catch { await MainActor.run { showProgressDialog = false diff --git a/TablePro/Views/Main/Extensions/MainContentView+TransferSheets.swift b/TablePro/Views/Main/Extensions/MainContentView+TransferSheets.swift new file mode 100644 index 000000000..d5d868355 --- /dev/null +++ b/TablePro/Views/Main/Extensions/MainContentView+TransferSheets.swift @@ -0,0 +1,108 @@ +// +// MainContentView+TransferSheets.swift +// TablePro +// + +import SwiftUI + +/// The sheets that move data in or out of a connection: export, import, transfer, backup, restore +/// and server-side export. They live apart from the rest because together they were more than half +/// of what `sheetContent(for:)` had to hold. +extension MainContentView { + @ViewBuilder + func transferSheetContent(for sheet: ActiveSheet, dismiss dismissBinding: Binding) -> some View { + switch sheet { + case .exportDialog: + let exportConnection = exportConnection + ExportDialog( + isPresented: dismissBinding, + mode: .tables( + connection: exportConnection, + preselection: coordinator.exportPreselection + ?? .tables(Set(coordinator.windowSidebarState.selectedTables.map(\.table.name))) + ), + sidebarTables: tables + ) + case .exportQueryResults: + if let tab = coordinator.tabManager.selectedTab { + let fileName = tab.tableContext.tableName ?? "query_results" + if tab.pagination.hasMoreRows, let baseQuery = tab.pagination.baseQueryForMore { + ExportDialog( + isPresented: dismissBinding, + mode: .streamingQuery( + connection: connectionWithCurrentDatabase, + query: baseQuery, + suggestedFileName: fileName + ) + ) + } else { + ExportDialog( + isPresented: dismissBinding, + mode: .queryResults( + connection: connectionWithCurrentDatabase, + tableRows: coordinator.tabSessionRegistry.tableRows(for: tab.id), + suggestedFileName: fileName + ) + ) + } + } + case .importDialog(let formatId): + let importDismiss = Binding( + get: { coordinator.activeSheet != nil }, + set: { if !$0 { + coordinator.activeSheet = nil + coordinator.importFileURL = nil + } + } + ) + ImportDialog( + isPresented: importDismiss, + connection: connection, + initialFileURL: coordinator.importFileURL, + initialFormatId: formatId + ) + case .rowImport(let formatId): + let rowDismiss = Binding( + get: { coordinator.activeSheet != nil }, + set: { if !$0 { + coordinator.activeSheet = nil + coordinator.importFileURL = nil + } + } + ) + if let url = coordinator.importFileURL { + RowImportSheet( + isPresented: rowDismiss, + connection: connection, + fileURL: url, + formatId: formatId + ) + } + case .transferTables(let tables): + transferSheet(tables: tables, dismiss: dismissBinding) + case .backupDatabase: + BackupDatabaseFlow( + isPresented: dismissBinding, + connection: connectionWithCurrentDatabase, + initialDatabase: DatabaseManager.shared.session(for: connection.id)?.browseDatabase + ?? connection.database + ) + case .restoreDatabase(let fileURL): + RestoreDatabaseFlow( + isPresented: dismissBinding, + connection: connectionWithCurrentDatabase, + initialDatabase: DatabaseManager.shared.session(for: connection.id)?.browseDatabase + ?? connection.database, + sourceURL: fileURL + ) + case .serverSideExport(let table): + ServerSideExportSheet( + isPresented: dismissBinding, + connection: connectionWithCurrentDatabase, + initialTable: table + ) + default: + EmptyView() + } + } +} diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index db21219b0..5db07911a 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -38,7 +38,7 @@ struct MainContentView: View { @Binding var tableOperationOptions: [DatabaseTreeTableRef: TableOperationOptions] var rightPanelState: RightPanelState - private var tables: [TableInfo] { + var tables: [TableInfo] { schemaService.tables(for: connection.id) } @@ -137,7 +137,7 @@ struct MainContentView: View { /// Connection with the active database from the current session, /// so export/import dialogs see the database the user actually switched to. - private var connectionWithCurrentDatabase: DatabaseConnection { + var connectionWithCurrentDatabase: DatabaseConnection { var conn = connection if let currentDB = DatabaseManager.shared.session(for: connection.id)?.browseDatabase { conn.database = currentDB @@ -148,7 +148,7 @@ struct MainContentView: View { /// Exporting a container names the database that container lives in, which is not always the /// one being browsed. The dialog scopes every list and the export itself to this connection's /// database, so naming it here is what makes exporting another database show that database. - private var exportConnection: DatabaseConnection { + var exportConnection: DatabaseConnection { var conn = connectionWithCurrentDatabase if let scoped = coordinator.exportPreselection?.scopedDatabase, !scoped.isEmpty { conn.database = scoped @@ -162,7 +162,7 @@ struct MainContentView: View { /// The transfer sheet is built here rather than inline, because `sheetContent(for:)` is one /// switch over every sheet the window can present and is already at the function length limit. @ViewBuilder - private func transferSheet(tables: Set, dismiss: Binding) -> some View { + func transferSheet(tables: Set, dismiss: Binding) -> some View { TableTransferSheet( isPresented: dismiss, sourceConnection: connectionWithCurrentDatabase, @@ -204,95 +204,9 @@ struct MainContentView: View { ) case .copyObjects(let launch): CopyObjectsSheet(launch: launch, connection: connection) - case .exportDialog: - let exportConnection = exportConnection - ExportDialog( - isPresented: dismissBinding, - mode: .tables( - connection: exportConnection, - preselection: coordinator.exportPreselection - ?? .tables(Set(coordinator.windowSidebarState.selectedTables.map(\.table.name))) - ), - sidebarTables: tables - ) - case .exportQueryResults: - if let tab = coordinator.tabManager.selectedTab { - let fileName = tab.tableContext.tableName ?? "query_results" - if tab.pagination.hasMoreRows, let baseQuery = tab.pagination.baseQueryForMore { - ExportDialog( - isPresented: dismissBinding, - mode: .streamingQuery( - connection: connectionWithCurrentDatabase, - query: baseQuery, - suggestedFileName: fileName - ) - ) - } else { - ExportDialog( - isPresented: dismissBinding, - mode: .queryResults( - connection: connectionWithCurrentDatabase, - tableRows: coordinator.tabSessionRegistry.tableRows(for: tab.id), - suggestedFileName: fileName - ) - ) - } - } - case .importDialog(let formatId): - let importDismiss = Binding( - get: { coordinator.activeSheet != nil }, - set: { if !$0 { - coordinator.activeSheet = nil - coordinator.importFileURL = nil - } - } - ) - ImportDialog( - isPresented: importDismiss, - connection: connection, - initialFileURL: coordinator.importFileURL, - initialFormatId: formatId - ) - case .rowImport(let formatId): - let rowDismiss = Binding( - get: { coordinator.activeSheet != nil }, - set: { if !$0 { - coordinator.activeSheet = nil - coordinator.importFileURL = nil - } - } - ) - if let url = coordinator.importFileURL { - RowImportSheet( - isPresented: rowDismiss, - connection: connection, - fileURL: url, - formatId: formatId - ) - } - case .transferTables(let tables): - transferSheet(tables: tables, dismiss: dismissBinding) - case .backupDatabase: - BackupDatabaseFlow( - isPresented: dismissBinding, - connection: connectionWithCurrentDatabase, - initialDatabase: DatabaseManager.shared.session(for: connection.id)?.browseDatabase - ?? connection.database - ) - case .restoreDatabase(let fileURL): - RestoreDatabaseFlow( - isPresented: dismissBinding, - connection: connectionWithCurrentDatabase, - initialDatabase: DatabaseManager.shared.session(for: connection.id)?.browseDatabase - ?? connection.database, - sourceURL: fileURL - ) - case .serverSideExport(let table): - ServerSideExportSheet( - isPresented: dismissBinding, - connection: connectionWithCurrentDatabase, - initialTable: table - ) + case .exportDialog, .exportQueryResults, .importDialog, .rowImport, + .transferTables, .backupDatabase, .restoreDatabase, .serverSideExport: + transferSheetContent(for: sheet, dismiss: dismissBinding) case .maintenance(let operation, let tableName, let database, let schema): MaintenanceSheet( operation: operation, diff --git a/TableProTests/Core/Export/ExportFormatCatalogTests.swift b/TableProTests/Core/Export/ExportFormatCatalogTests.swift new file mode 100644 index 000000000..0ee44d864 --- /dev/null +++ b/TableProTests/Core/Export/ExportFormatCatalogTests.swift @@ -0,0 +1,122 @@ +// +// ExportFormatCatalogTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +/// The catalog only reads a plugin's statics, so these stand in for the real formats without +/// pulling in a bundle. Plugins never load under XCTest, so the real ones are not available. +private protocol StubExportFormat: ExportFormatPlugin {} + +private extension StubExportFormat { + static var defaultFileExtension: String { formatId } + static var iconName: String { "doc" } + + func export( + tables: [PluginExportTable], + dataSource: any PluginExportDataSource, + destination: URL, + progress: PluginExportProgress + ) async throws -> ExportFormatResult { + ExportFormatResult() + } +} + +private final class StubCSV: StubExportFormat, @unchecked Sendable { + static let pluginName = "CSV" + static let pluginVersion = "1.0.0" + static let pluginDescription = "Export data to CSV format" + static let formatId = "csv" + static let formatDisplayName = "CSV" + init() {} +} + +private final class StubSQL: StubExportFormat, @unchecked Sendable { + static let pluginName = "SQL" + static let pluginVersion = "1.0.0" + static let pluginDescription = "Export data to SQL format" + static let formatId = "sql" + static let formatDisplayName = "SQL" + init() {} +} + +private final class StubParquet: StubExportFormat, @unchecked Sendable { + static let pluginName = "Parquet" + static let pluginVersion = "1.0.0" + static let pluginDescription = "Export data to Apache Parquet" + static let formatId = "parquet" + static let formatDisplayName = "Parquet" + init() {} +} + +private final class StubZebra: StubExportFormat, @unchecked Sendable { + static let pluginName = "Zebra" + static let pluginVersion = "1.0.0" + static let pluginDescription = "Writes zebra files" + static let formatId = "zebra" + static let formatDisplayName = "Zebra" + init() {} +} + +private final class StubAardvark: StubExportFormat, @unchecked Sendable { + static let pluginName = "Aardvark" + static let pluginVersion = "1.0.0" + static let pluginDescription = "Writes aardvark files" + static let formatId = "aardvark" + static let formatDisplayName = "Aardvark" + init() {} +} + +@Suite("Export format catalog") +struct ExportFormatCatalogTests { + + private func ids(_ plugins: [any ExportFormatPlugin]) -> [String] { + plugins.map { type(of: $0).formatId } + } + + /// Parquet shipped in neither the order nor the description table, so it tied with every other + /// unknown format at the end of the list and showed no description at all. + @Test("Parquet is ordered and described like the formats that shipped before it") + func parquetIsCurated() { + let sorted = ExportFormatCatalog.sorted([StubParquet(), StubCSV(), StubSQL()]) + #expect(ids(sorted) == ["csv", "sql", "parquet"]) + + let description = ExportFormatCatalog.description(for: StubParquet()) + #expect(!description.isEmpty) + #expect(description != StubParquet.pluginDescription) + } + + /// A registry format installed after this app was built is not in the curated list. It has to + /// land somewhere stable rather than tying with every other unknown one. + @Test("An unknown format sorts after the curated ones, by display name") + func unknownFormatsSortLast() { + let sorted = ExportFormatCatalog.sorted([StubZebra(), StubAardvark(), StubSQL()]) + #expect(ids(sorted) == ["sql", "aardvark", "zebra"]) + } + + @Test("An unknown format describes itself from the plugin") + func unknownFormatDescribesItself() { + #expect(ExportFormatCatalog.description(for: StubZebra()) == "Writes zebra files") + } + + @Test("A curated format is described by the catalog, not by the plugin") + func curatedFormatUsesTheCatalog() { + #expect(ExportFormatCatalog.description(for: StubCSV()) != StubCSV.pluginDescription) + #expect(ExportFormatCatalog.description(for: StubCSV()).contains("Excel")) + } + + @Test("Sorting does not depend on the order it is handed") + func sortIsOrderIndependent() { + let forwards = ids( + ExportFormatCatalog.sorted([StubCSV(), StubSQL(), StubParquet(), StubZebra()])) + let backwards = ids( + ExportFormatCatalog.sorted([StubZebra(), StubParquet(), StubSQL(), StubCSV()])) + #expect(forwards == backwards) + #expect(forwards == ["csv", "sql", "parquet", "zebra"]) + } +} diff --git a/TableProTests/Database/NativeDumpServiceTests.swift b/TableProTests/Database/NativeDumpServiceTests.swift index df3afe6a5..61bb21f10 100644 --- a/TableProTests/Database/NativeDumpServiceTests.swift +++ b/TableProTests/Database/NativeDumpServiceTests.swift @@ -288,7 +288,7 @@ struct NativeDumpServiceStateMachineTests { runner.finish(.init(exitCode: 1, stderr: "FATAL: connection refused", wasCancelled: false)) let finalState = try await firstMatching(updates) { if case .failed = $0 { return true }; return false } - if case .failed(let message) = finalState { + if case .failed(let message, _) = finalState { #expect(message == "FATAL: connection refused") } else { Issue.record("expected failed, got \(finalState)") @@ -350,7 +350,7 @@ struct NativeDumpServiceStateMachineTests { runner.finish(.init(exitCode: 42, stderr: "", wasCancelled: false)) let finalState = try await firstMatching(updates) { if case .failed = $0 { return true }; return false } - if case .failed(let message) = finalState { + if case .failed(let message, _) = finalState { #expect(message.contains("42")) } else { Issue.record("expected failed, got \(finalState)") diff --git a/TableProTests/Database/ServerSideExportTests.swift b/TableProTests/Database/ServerSideExportTests.swift index f2f5ebc86..dd6dad897 100644 --- a/TableProTests/Database/ServerSideExportTests.swift +++ b/TableProTests/Database/ServerSideExportTests.swift @@ -89,6 +89,39 @@ struct ServerSideExportTests { #expect(statement(.oracle, destination: .oracleDirectory(name: "")) == nil) } + /// An Oracle identifier may hold a quote. Left raw it closes the literal it sits in and hands + /// the rest of the name to the PL/SQL parser, which is the whole reason the other two arms + /// take an escaping function. + @Test("Oracle escapes a quote in the table name through both levels of quoting") + func oracleEscapesTableName() throws { + let sql = try #require( + statement(.oracle, destination: .oracleDirectory(name: "d"), table: "it's")) + #expect(sql.contains(#"'IN (''IT''''S'')'"#)) + #expect(!sql.contains(#"'IN (''IT'S'')'"#)) + } + + @Test("Oracle escapes a quote in the schema name") + func oracleEscapesSchemaName() throws { + let sql = try #require( + statement(.oracle, destination: .oracleDirectory(name: "d"), schema: "o'brien")) + #expect(sql.contains(#"'IN (''O''''BRIEN'')'"#)) + } + + @Test("Oracle escapes a quote in the directory object name") + func oracleEscapesDirectory() throws { + let sql = try #require(statement(.oracle, destination: .oracleDirectory(name: "dir's"))) + #expect(sql.contains(#"'DIR''S'"#)) + #expect(!sql.contains(#"'DIR'S'"#)) + } + + /// `USER` has to be concatenated in PL/SQL, not written inside the literal: the expression the + /// filter receives is a string, and an identifier written into one is only ever that text. + @Test("The session-user fallback concatenates USER rather than quoting it") + func oracleUserFallbackIsConcatenated() throws { + let sql = try #require(statement(.oracle, destination: .oracleDirectory(name: "d"))) + #expect(sql.contains(#"'SCHEMA_EXPR', 'IN (''' || USER || ''')'"#)) + } + // MARK: - Snowflake @Test("Snowflake copies into the stage and takes the format's own options") diff --git a/docs/features/backup-restore.mdx b/docs/features/backup-restore.mdx index 312337353..ef159fae8 100644 --- a/docs/features/backup-restore.mdx +++ b/docs/features/backup-restore.mdx @@ -51,6 +51,9 @@ The lookup takes the first match from `/usr/bin/which`, then `/opt/homebrew/bin` `pg_restore` runs with `--no-owner --no-acl`, so the connection user ends up owning the restored objects. `mongorestore` is scoped with `--nsInclude` to the database you pick. + + The dump is replayed into a database that already has contents. Objects it names are overwritten. + Restore shows no percentage. **Cancel** confirms and sends `SIGTERM`, and the target database is left as it stands: drop it and restore into a fresh one, or clean up the partial objects yourself. @@ -74,6 +77,10 @@ Pick a table and name the destination. Oracle takes the name of a `DIRECTORY` ob The statement runs on your own connection, so it carries your privileges and the server's own error comes back when the destination is not writable. +Snowflake and BigQuery block until the unload finishes, so the file exists by the time the sheet says so. **Stop** asks the server to cancel one and stops waiting either way. + +Oracle is different. The Data Pump block starts a job and detaches, so the statement returns before anything is written and the sheet says the job was started rather than finished. Watch `DBA_DATAPUMP_JOBS` for its progress, and stop it there. + Nothing lands on your Mac. The result is on the server or in the bucket, and the sheet says where it went. diff --git a/docs/features/import-export.mdx b/docs/features/import-export.mdx index 1f9e9a9bf..ad60048e7 100644 --- a/docs/features/import-export.mdx +++ b/docs/features/import-export.mdx @@ -44,6 +44,8 @@ SQL exports more than tables. A database holding more than one kind of object gr A database with only tables lists them flat, with no group to open first. +Arrow keys move through the tree, Left and Right close and open a group, and Space ticks every selected row. Ticking a group ticks everything under it. + Only SQL writes every group. CSV, JSON and XLSX take tables and views, and switching to one of them drops the rest from the tree. Per-object checkboxes follow the kind: **Data** is off the row for anything without rows, and a privilege row carries neither **Drop** nor **Data**.