diff --git a/CHANGELOG.md b/CHANGELOG.md index b4a4acc5d..d9b744c47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- File > Backup... for PostgreSQL and Redshift connections: pick a database from the current connection, choose a destination, and run `pg_dump` in custom archive format (`-Fc`) with progress and cancel. Reuses the existing SSH tunnel when one is active, and honors a custom pg_dump path under Settings > Terminal > CLI Paths. +- File > Restore... for PostgreSQL and Redshift connections: pick a `pg_dump` backup file, pick a target database on the current connection, and run `pg_restore --no-owner --no-acl` with progress and cancel. Reuses the existing SSH tunnel, and honors a custom pg_restore path under Settings > Terminal > CLI Paths. - Sidebar groups database objects into Tables, Views, Materialized Views, Foreign Tables, Procedures, and Functions sections; routines load automatically on connect for Postgres and MySQL, each section header has its own Refresh action, and "Show DDL" on a procedure or function opens its definition in a new query tab (#1038) - iOS: Live Activity for running queries shows query preview, elapsed time, and row count on the lock screen and Dynamic Island - iOS: multi-window support on iPad - drag a tab off to open a second window, each window remembers its own selected connection across launches diff --git a/TablePro/Core/Database/PostgresDumpService.swift b/TablePro/Core/Database/PostgresDumpService.swift new file mode 100644 index 000000000..e9a225d4a --- /dev/null +++ b/TablePro/Core/Database/PostgresDumpService.swift @@ -0,0 +1,442 @@ +// +// PostgresDumpService.swift +// TablePro +// +// Consolidated backup + restore state machine for PostgreSQL connections. +// The actual Process execution is delegated to a `DumpRunner` so the +// state machine can be exercised in tests with a fake runner. +// + +import Foundation +import Observation +import os +import TableProPluginKit + +// MARK: - Public Types + +/// What the service is doing: dump (back up) a database or restore a dump file. +enum PostgresDumpKind: Equatable, Sendable { + case backup + case restore +} + +/// Observable state of a backup or restore. +enum PostgresDumpState: Equatable { + case idle + case running(database: String, fileURL: URL, bytesProcessed: Int64, totalBytes: Int64?) + case cancelling + case finished(database: String, fileURL: URL, bytesProcessed: Int64) + case failed(message: String) + case cancelled +} + +enum PostgresDumpError: LocalizedError, Equatable { + case binaryNotFound(name: String) + case unsupportedDatabase + case noSession + case alreadyRunning + case sourceUnreadable + + var errorDescription: String? { + switch self { + case .binaryNotFound(let name): + return String( + format: String(localized: """ + %@ was not found on this system. Install it with `brew install libpq` and \ + link it, or set a custom path under Settings > Terminal > CLI Paths > %@. + """), + name, name + ) + case .unsupportedDatabase: + return String(localized: "Dump operations are only supported for PostgreSQL and Redshift connections.") + case .noSession: + return String(localized: "Connect to the database before starting this operation.") + case .alreadyRunning: + return String(localized: "An operation is already running.") + case .sourceUnreadable: + return String(localized: "The selected backup file is not readable.") + } + } +} + +/// Parameters for a single backup or restore command. +struct PostgresDumpCommand: Equatable { + let executable: URL + let arguments: [String] + let environment: [String: String] + let stderrByteCap: Int +} + +/// Captured terminal state of a finished/cancelled subprocess. +struct PostgresDumpRunResult: Equatable { + let exitCode: Int32 + let stderr: String + let wasCancelled: Bool +} + +/// Spawns and supervises a single subprocess. Abstracted so the dump +/// state machine can be tested without launching real processes. +protocol PostgresDumpRunner: AnyObject { + /// Launches the command. Throws synchronously if the binary can't be spawned. + /// `result` returns the final outcome when the process exits. + func start(_ command: PostgresDumpCommand) throws + /// Sends SIGTERM. Safe to call multiple times. + func cancel() + /// Resolves once the process has terminated (normally or via cancel). + var result: PostgresDumpRunResult { get async } +} + +// MARK: - Service + +@MainActor +@Observable +final class PostgresDumpService { + nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "PostgresDumpService") + + let kind: PostgresDumpKind + private(set) var state: PostgresDumpState = .idle + + @ObservationIgnored private let runnerFactory: () -> any PostgresDumpRunner + @ObservationIgnored private var runner: (any PostgresDumpRunner)? + @ObservationIgnored private var byteSizeTask: Task? + + /// Default initializer uses the real `Process`-backed runner. + init(kind: PostgresDumpKind) { + self.kind = kind + self.runnerFactory = { ProcessPostgresDumpRunner() } + } + + /// Test-friendly initializer that injects a custom runner factory. + init(kind: PostgresDumpKind, runnerFactory: @escaping () -> any PostgresDumpRunner) { + self.kind = kind + self.runnerFactory = runnerFactory + } + + /// Starts the operation. `fileURL` is the destination for `.backup` and + /// the source for `.restore`. `totalBytesEstimate` enables a determinate + /// progress bar (used by backup; restore stays indeterminate). + /// + /// This entry point resolves dependencies from app singletons + /// (`DatabaseManager`, `ConnectionStorage`, `AppSettingsManager`, + /// `CLICommandResolver`). Tests should use `run(command:database:fileURL:totalBytesEstimate:)` + /// directly with a fake runner. + func start( + connection: DatabaseConnection, + database: String, + fileURL: URL, + totalBytesEstimate: Int64? = nil + ) async throws { + if case .running = state { throw PostgresDumpError.alreadyRunning } + if case .cancelling = state { throw PostgresDumpError.alreadyRunning } + + guard connection.type == .postgresql || connection.type == .redshift else { + throw PostgresDumpError.unsupportedDatabase + } + + let session = DatabaseManager.shared.session(for: connection.id) + guard session?.isConnected == true else { throw PostgresDumpError.noSession } + + if kind == .restore { + guard FileManager.default.isReadableFile(atPath: fileURL.path) else { + throw PostgresDumpError.sourceUnreadable + } + } + + let effective = session?.effectiveConnection ?? connection + let password = ConnectionStorage.shared.loadPassword(for: connection.id) ?? session?.cachedPassword + + let cliKey: String + let binaryName: String + switch kind { + case .backup: + cliKey = TerminalSettings.pgDumpCliPathKey + binaryName = "pg_dump" + case .restore: + cliKey = TerminalSettings.pgRestoreCliPathKey + binaryName = "pg_restore" + } + let customPath = AppSettingsManager.shared.terminal.cliPaths[cliKey]?.nilIfEmpty + guard let resolvedPath = CLICommandResolver.findExecutable(binaryName, customPath: customPath) else { + throw PostgresDumpError.binaryNotFound(name: binaryName) + } + + let command = Self.buildCommand( + kind: kind, + executable: URL(fileURLWithPath: resolvedPath), + effective: effective, + database: database, + fileURL: fileURL, + password: password + ) + + try run( + command: command, + database: database, + fileURL: fileURL, + totalBytesEstimate: totalBytesEstimate + ) + Self.logger.info("\(binaryName, privacy: .public) started db=\(database, privacy: .public)") + } + + /// Test-friendly entry: spawns the given pre-built command via the runner + /// and wires up termination/progress state. Skips dependency resolution. + func run( + command: PostgresDumpCommand, + database: String, + fileURL: URL, + totalBytesEstimate: Int64? = nil + ) throws { + if case .running = state { throw PostgresDumpError.alreadyRunning } + if case .cancelling = state { throw PostgresDumpError.alreadyRunning } + + let runner = runnerFactory() + try runner.start(command) + self.runner = runner + + state = .running(database: database, fileURL: fileURL, bytesProcessed: 0, totalBytes: totalBytesEstimate) + if kind == .backup { + startByteSizePolling(url: fileURL, database: database, totalBytes: totalBytesEstimate) + } + + Task { @MainActor [weak self] in + guard let result = await self?.runner?.result else { return } + self?.handleTermination(result: result, database: database, fileURL: fileURL) + } + } + + func cancel() { + guard case .running = state else { return } + state = .cancelling + runner?.cancel() + } + + // MARK: - Command Construction + + static func buildCommand( + kind: PostgresDumpKind, + executable: URL, + effective: DatabaseConnection, + database: String, + fileURL: URL, + password: String? + ) -> PostgresDumpCommand { + var args: [String] = ["--no-password"] + args.append(contentsOf: ["-h", effective.host.isEmpty ? "127.0.0.1" : effective.host]) + args.append(contentsOf: ["-p", String(effective.port)]) + if !effective.username.isEmpty { + args.append(contentsOf: ["-U", effective.username]) + } + switch kind { + case .backup: + args.append("-Fc") + args.append(contentsOf: ["-d", database]) + args.append(contentsOf: ["-f", fileURL.path]) + case .restore: + args.append("--no-owner") + args.append("--no-acl") + args.append(contentsOf: ["-d", database]) + args.append(fileURL.path) + } + + var env = ProcessInfo.processInfo.environment + if let password, !password.isEmpty { + env["PGPASSWORD"] = password + } + if effective.sslConfig.isEnabled { + env["PGSSLMODE"] = pgSSLMode(effective.sslConfig.mode) + } + return PostgresDumpCommand( + executable: executable, + arguments: args, + environment: env, + stderrByteCap: 64_000 + ) + } + + static func pgSSLMode(_ mode: SSLMode) -> String { + switch mode { + case .disabled: return "disable" + case .preferred: return "prefer" + case .required: return "require" + case .verifyCa: return "verify-ca" + case .verifyIdentity: return "verify-full" + } + } + + // MARK: - Termination + Progress + + private func handleTermination( + result: PostgresDumpRunResult, + database: String, + fileURL: URL + ) { + byteSizeTask?.cancel() + byteSizeTask = nil + runner = nil + + let writtenBytes: Int64 + if kind == .backup { + writtenBytes = (try? FileManager.default.attributesOfItem(atPath: fileURL.path)[.size] as? Int64) ?? 0 + } else { + writtenBytes = 0 + } + + if result.wasCancelled { + if kind == .backup { + try? FileManager.default.removeItem(at: fileURL) + } + state = .cancelled + Self.logger.notice("\(self.kind == .backup ? "pg_dump" : "pg_restore", privacy: .public) cancelled db=\(database, privacy: .public)") + return + } + + if result.exitCode == 0 { + state = .finished(database: database, fileURL: fileURL, bytesProcessed: writtenBytes) + Self.logger.info("\(self.kind == .backup ? "pg_dump" : "pg_restore", privacy: .public) finished bytes=\(writtenBytes) db=\(database, privacy: .public)") + return + } + + if kind == .backup { + try? FileManager.default.removeItem(at: fileURL) + } + let summary = result.stderr.isEmpty + ? String(format: String(localized: "Process exited with code %d"), Int(result.exitCode)) + : result.stderr + state = .failed(message: summary) + Self.logger.error("\(self.kind == .backup ? "pg_dump" : "pg_restore", privacy: .public) failed code=\(result.exitCode) db=\(database, privacy: .public) stderr=\(result.stderr, privacy: .public)") + } + + private func startByteSizePolling(url: URL, database: String, totalBytes: Int64?) { + byteSizeTask?.cancel() + byteSizeTask = Task { @MainActor [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 250_000_000) + guard let self else { return } + guard case .running = self.state else { return } + let size = (try? FileManager.default + .attributesOfItem(atPath: url.path)[.size] as? Int64) ?? 0 + self.state = .running( + database: database, + fileURL: url, + bytesProcessed: size, + totalBytes: totalBytes + ) + } + } + } +} + +// MARK: - Helpers + +private extension String { + var nilIfEmpty: String? { isEmpty ? nil : self } +} + +// MARK: - Real Process Runner + +/// Concrete `PostgresDumpRunner` that spawns a real subprocess. +/// stderr is accumulated entirely off the main actor inside the +/// `readabilityHandler` closure and the termination handler; +/// only the final string crosses back to MainActor through `result`. +final class ProcessPostgresDumpRunner: PostgresDumpRunner { + private let process = Process() + private let stderrPipe = Pipe() + private let bufferLock = NSLock() + private var stderrBuffer = Data() + private var stderrCap = 64_000 + private var wasCancelled = false + private var continuation: CheckedContinuation? + + func start(_ command: PostgresDumpCommand) throws { + stderrCap = command.stderrByteCap + + process.executableURL = command.executable + process.arguments = command.arguments + process.environment = command.environment + process.standardError = stderrPipe + process.standardOutput = FileHandle.nullDevice + + stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let chunk = handle.availableData + guard !chunk.isEmpty, let self else { return } + self.bufferLock.lock() + self.stderrBuffer.append(chunk) + if self.stderrBuffer.count > self.stderrCap { + self.stderrBuffer = Data(self.stderrBuffer.suffix(self.stderrCap)) + } + self.bufferLock.unlock() + } + + process.terminationHandler = { [weak self] proc in + guard let self else { return } + self.stderrPipe.fileHandleForReading.readabilityHandler = nil + + self.bufferLock.lock() + let stderrText = String(data: self.stderrBuffer, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + self.bufferLock.unlock() + + let result = PostgresDumpRunResult( + exitCode: proc.terminationStatus, + stderr: stderrText, + wasCancelled: self.wasCancelled + ) + self.continuation?.resume(returning: result) + self.continuation = nil + } + + try process.run() + } + + func cancel() { + wasCancelled = true + if process.isRunning { + process.terminate() + } + } + + var result: PostgresDumpRunResult { + get async { + await withCheckedContinuation { continuation in + if !process.isRunning { + self.bufferLock.lock() + let stderrText = String(data: self.stderrBuffer, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + self.bufferLock.unlock() + continuation.resume(returning: PostgresDumpRunResult( + exitCode: process.terminationStatus, + stderr: stderrText, + wasCancelled: wasCancelled + )) + } else { + self.continuation = continuation + } + } + } + } +} + +// MARK: - Database Size Helper + +extension PostgresDumpService { + /// Best-effort estimate of the database's on-disk size. Used as an upper + /// bound for the backup progress bar; the dump file is typically much + /// smaller because of compression, so the bar tops out at the size and + /// then jumps when pg_dump exits. + /// Returns nil if the query fails or the driver isn't connected. + static func estimatedDatabaseSize( + connection: DatabaseConnection, + database: String + ) async -> Int64? { + guard let driver = DatabaseManager.shared.driver(for: connection.id) else { return nil } + let escaped = database.replacingOccurrences(of: "'", with: "''") + let query = "SELECT pg_database_size('\(escaped)')" + do { + let result = try await driver.execute(query: query) + guard let text = result.rows.first?.first?.asText else { return nil } + return Int64(text) + } catch { + return nil + } + } +} diff --git a/TablePro/Models/Settings/AppSettings.swift b/TablePro/Models/Settings/AppSettings.swift index b4b69584d..188fe0d59 100644 --- a/TablePro/Models/Settings/AppSettings.swift +++ b/TablePro/Models/Settings/AppSettings.swift @@ -347,9 +347,18 @@ struct TerminalSettings: Codable, Equatable { var bellEnabled: Bool = true var themeName: String = "" - /// Per-database CLI path overrides (empty = auto-detect) + /// Per-database CLI path overrides (empty = auto-detect). + /// Keys are `DatabaseType.rawValue` for interactive CLIs, plus + /// `TerminalSettings.pgDumpCliPathKey` and `TerminalSettings.pgRestoreCliPathKey` + /// for the PostgreSQL backup/restore binaries. var cliPaths: [String: String] = [:] + /// Key under `cliPaths` for the pg_dump backup binary path. + static let pgDumpCliPathKey = "pg_dump" + + /// Key under `cliPaths` for the pg_restore binary path. + static let pgRestoreCliPathKey = "pg_restore" + static let `default` = TerminalSettings() init( diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 31b9b2917..a290d48ae 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -3515,6 +3515,9 @@ } } } + }, + "A backup is already running." : { + }, "A built-in plugin \"%@\" already provides this bundle ID" : { "localizations" : { @@ -3584,6 +3587,9 @@ } } } + }, + "A restore is already running." : { + }, "A sync conflict was detected and needs to be resolved." : { "localizations" : { @@ -7409,6 +7415,33 @@ } } }, + "Backup Dump" : { + "comment" : "A button that triggers a backup of the current database.", + "isCommentAutoGenerated" : true + }, + "Backup Dump Cancelled" : { + "comment" : "A title for a backup result sheet that was cancelled.", + "isCommentAutoGenerated" : true + }, + "Backup Dump Complete" : { + "comment" : "A title for a backup result sheet when the backup was successful.", + "isCommentAutoGenerated" : true + }, + "Backup Dump Failed" : { + "comment" : "A title for a backup result sheet when the backup failed.", + "isCommentAutoGenerated" : true + }, + "Backup Dump..." : { + + }, + "Backup Dump…" : { + "comment" : "A button that triggers a backup dump.", + "isCommentAutoGenerated" : true + }, + "Backups are only supported for PostgreSQL and Redshift connections." : { + "comment" : "Error message displayed when attempting to create a backup of a database that is not PostgreSQL or Redshift.", + "isCommentAutoGenerated" : true + }, "Badge Background" : { "localizations" : { "tr" : { @@ -8153,6 +8186,14 @@ } } }, + "Cancel Backup Dump" : { + "comment" : "A button that cancels a backup.", + "isCommentAutoGenerated" : true + }, + "Cancel Backup Dump?" : { + "comment" : "A confirmation dialog for cancelling a backup.", + "isCommentAutoGenerated" : true + }, "Cancel Query" : { "localizations" : { "tr" : { @@ -8219,6 +8260,14 @@ } } }, + "Cancel Restore Dump" : { + "comment" : "A button that cancels a restore dump.", + "isCommentAutoGenerated" : true + }, + "Cancel Restore Dump?" : { + "comment" : "A confirmation prompt for cancelling a restore operation.", + "isCommentAutoGenerated" : true + }, "Cancelled" : { }, @@ -8243,6 +8292,9 @@ } } } + }, + "Cancelling…" : { + }, "Cannot connect to Ollama at %@. Is Ollama running?" : { "localizations" : { @@ -8899,6 +8951,10 @@ }, "Chinook (Sample)" : { + }, + "Choose" : { + "comment" : "Button text for choosing a backup file.", + "isCommentAutoGenerated" : true }, "Choose a certificate or key file" : { "localizations" : { @@ -9043,6 +9099,14 @@ }, "Choose AI provider and model" : { + }, + "Choose Backup File" : { + "comment" : "Title of the dialog that opens to choose a backup file.", + "isCommentAutoGenerated" : true + }, + "Choose where to save the backup of “%@”." : { + "comment" : "A message that appears in the body of a dialog box that appears when the user is prompted to choose a destination for a backup. The argument is the name of the database that is being backed up.", + "isCommentAutoGenerated" : true }, "Choose your client and follow the steps to connect it to TablePro." : { @@ -10985,6 +11049,12 @@ } } } + }, + "Connect to the database before starting a backup." : { + + }, + "Connect to the database before starting a restore." : { + }, "Connect to the internet to verify your license." : { "localizations" : { @@ -13310,6 +13380,10 @@ } } }, + "Creating Backup Dump" : { + "comment" : "A title for a backup progress sheet.", + "isCommentAutoGenerated" : true + }, "Creating..." : { "localizations" : { "tr" : { @@ -34084,6 +34158,20 @@ } } }, + "pg_dump exited with code %d" : { + "comment" : "A summary of an error that might occur when backing up a database.", + "isCommentAutoGenerated" : true + }, + "pg_dump was not found on this system. Install it with `brew install libpq` and link it, or set a custom path under Settings > Terminal > CLI Paths > pg_dump." : { + + }, + "pg_restore exited with code %d" : { + + }, + "pg_restore was not found on this system. Install it with `brew install libpq` and link it, or set a custom path under Settings > Terminal > CLI Paths > pg_restore." : { + "comment" : "Error message when pg_restore is not found on the system.", + "isCommentAutoGenerated" : true + }, "Pick the type of database you want to connect to." : { }, @@ -38652,6 +38740,36 @@ } } } + }, + "Restore Dump" : { + "comment" : "A button that opens a dialog for restoring a database from a dump.", + "isCommentAutoGenerated" : true + }, + "Restore Dump Cancelled" : { + "comment" : "A title for a result sheet that indicates a restore operation was cancelled.", + "isCommentAutoGenerated" : true + }, + "Restore Dump Complete" : { + "comment" : "A title for a backup result sheet that indicates a successful restore.", + "isCommentAutoGenerated" : true + }, + "Restore Dump Failed" : { + "comment" : "A title for a backup result sheet that indicates a failed restore.", + "isCommentAutoGenerated" : true + }, + "Restore Dump..." : { + + }, + "Restore Dump…" : { + "comment" : "A button that opens a dialog to restore a database from a dump.", + "isCommentAutoGenerated" : true + }, + "Restore from" : { + "comment" : "A label displayed above the name of the source database.", + "isCommentAutoGenerated" : true + }, + "Restore is only supported for PostgreSQL and Redshift connections." : { + }, "Restore Last Filter" : { "localizations" : { @@ -38675,6 +38793,20 @@ } } }, + "Restored “%@” from %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Restored “%1$@” from %2$@" + } + } + } + }, + "Restoring Dump" : { + "comment" : "A title for a progress sheet that is displayed when restoring a database dump.", + "isCommentAutoGenerated" : true + }, "Restrict to a specific connection (UUID, optional)" : { }, @@ -39846,6 +39978,9 @@ } } } + }, + "Save Backup" : { + }, "Save Changes" : { "localizations" : { @@ -40123,6 +40258,18 @@ } } }, + "Saved %@ of “%@” to %@" : { + "comment" : "A detail line that shows the size of the backed-up database and the path of the backup destination.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Saved %1$@ of “%2$@” to %3$@" + } + } + } + }, "Saved Connections" : { "localizations" : { "tr" : { @@ -40897,6 +41044,9 @@ } } } + }, + "Select a backup file created with pg_dump custom or directory format." : { + }, "Select a Plugin" : { "localizations" : { @@ -46880,8 +47030,15 @@ } } }, + "The partial backup file will be removed." : { + "comment" : "A message displayed in a cancel confirmation alert for a backup.", + "isCommentAutoGenerated" : true + }, "The previous code wasn't accepted. Wait for your authenticator to refresh, then enter the new code." : { + }, + "The selected backup file is not readable." : { + }, "The server will be accessible from other devices on your network. Authentication and TLS are enabled automatically." : { "localizations" : { @@ -46905,6 +47062,14 @@ } } }, + "The target database may be in a partial state. Review the database and clean up as needed." : { + "comment" : "A message that appears when restoring a database and the user is advised to review and clean up the database.", + "isCommentAutoGenerated" : true + }, + "The target database may be left in a partial state." : { + "comment" : "A message displayed in a cancel confirmation alert for a database restore.", + "isCommentAutoGenerated" : true + }, "The text could not be parsed as JSON." : { "localizations" : { "tr" : { @@ -52125,5 +52290,5 @@ } } }, - "version" : "1.0" + "version" : "1.1" } \ No newline at end of file diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index c7cd4077a..aa0977c11 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -299,6 +299,20 @@ struct AppMenuCommands: Commands { || actions?.isReadOnly ?? false || !(actions.map { PluginManager.shared.supportsImport(for: $0.currentDatabaseType) } ?? true) ) + + Button(String(localized: "Backup Dump...")) { + actions?.backupDatabase() + } + .disabled(!(actions?.isConnected ?? false) || !(actions?.supportsBackup ?? false)) + + Button(String(localized: "Restore Dump...")) { + actions?.restoreDatabase() + } + .disabled( + !(actions?.isConnected ?? false) + || !(actions?.supportsRestore ?? false) + || actions?.isReadOnly ?? false + ) } // Query menu diff --git a/TablePro/ViewModels/DatabaseSwitcherViewModel.swift b/TablePro/ViewModels/DatabaseSwitcherViewModel.swift index 06414556d..f0d034ed0 100644 --- a/TablePro/ViewModels/DatabaseSwitcherViewModel.swift +++ b/TablePro/ViewModels/DatabaseSwitcherViewModel.swift @@ -60,14 +60,19 @@ final class DatabaseSwitcherViewModel { init( connectionId: UUID, currentDatabase: String?, currentSchema: String?, - databaseType: DatabaseType, services: AppServices = .live + databaseType: DatabaseType, services: AppServices = .live, + initialMode: Mode? = nil ) { self.connectionId = connectionId self.currentDatabase = currentDatabase self.currentSchema = currentSchema self.databaseType = databaseType self.services = services - self.mode = services.pluginManager.supportsSchemaSwitching(for: databaseType) ? .schema : .database + if let initialMode { + self.mode = initialMode + } else { + self.mode = services.pluginManager.supportsSchemaSwitching(for: databaseType) ? .schema : .database + } } // MARK: - Public Methods diff --git a/TablePro/Views/Backup/BackupDatabaseFlow.swift b/TablePro/Views/Backup/BackupDatabaseFlow.swift new file mode 100644 index 000000000..a914c6790 --- /dev/null +++ b/TablePro/Views/Backup/BackupDatabaseFlow.swift @@ -0,0 +1,164 @@ +// +// BackupDatabaseFlow.swift +// TablePro +// +// Top-level sheet for the Backup Dump menu item. Reuses +// `DatabaseSwitcherSheet` in `.backup` mode to pick the database, +// then drives an NSSavePanel sub-sheet and the consolidated +// `PostgresDumpService` progress flow. +// + +import AppKit +import SwiftUI +import UniformTypeIdentifiers + +struct BackupDatabaseFlow: View { + @Binding var isPresented: Bool + let connection: DatabaseConnection + let initialDatabase: String + + @State private var service = PostgresDumpService(kind: .backup) + @State private var phase: Phase = .pickDatabase + + 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 cancelled + } + + var body: some View { + Group { + switch phase { + case .pickDatabase: + pickerView + case .running(let database, let totalBytes): + BackupProgressSheet( + kind: .backup, + database: database, + bytesWritten: bytesWritten, + totalBytes: totalBytes, + isCancelling: service.state == .cancelling, + onCancel: { service.cancel() } + ) + case .finished(let database, let destination, let bytes): + BackupResultSheet( + kind: .backup, + outcome: .backupSuccess(database: database, destination: destination, bytes: bytes), + onClose: { isPresented = false }, + onShowInFinder: { NSWorkspace.shared.activateFileViewerSelecting([destination]) } + ) + case .failed(let message): + BackupResultSheet( + kind: .backup, + outcome: .failure(message: message), + onClose: { isPresented = false }, + onShowInFinder: nil + ) + case .cancelled: + BackupResultSheet( + kind: .backup, + outcome: .cancelled, + onClose: { isPresented = false }, + onShowInFinder: nil + ) + } + } + .onChange(of: serviceState) { _, newState in + handleServiceStateChange(newState) + } + } + + private var pickerView: some View { + DatabaseSwitcherSheet( + isPresented: $isPresented, + mode: .backup, + currentDatabase: initialDatabase, + databaseType: connection.type, + connectionId: connection.id, + onSelect: { database in + Task { await promptForDestination(database: database) } + } + ) + } + + private var bytesWritten: Int64 { + if case .running(_, _, let bytes, _) = service.state { return bytes } + return 0 + } + + /// Hashable snapshot of `service.state` so SwiftUI's `onChange` fires on every transition. + private var serviceState: PostgresDumpState { service.state } + + private func handleServiceStateChange(_ state: PostgresDumpState) { + switch state { + case .running(let database, _, _, let totalBytes): + phase = .running(database: database, totalBytes: totalBytes) + case .finished(let database, let fileURL, let bytes): + phase = .finished(database: database, destination: fileURL, bytes: bytes) + case .failed(let message): + phase = .failed(message: message) + case .cancelled: + phase = .cancelled + case .idle, .cancelling: + break + } + } + + private func promptForDestination(database: String) async { + let savePanel = NSSavePanel() + savePanel.canCreateDirectories = true + savePanel.showsTagField = false + savePanel.allowedContentTypes = [UTType(filenameExtension: "dump") ?? .data] + savePanel.nameFieldStringValue = Self.defaultFilename(database: database) + 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 { + response = await savePanel.beginSheetModal(for: window) + } else { + response = savePanel.runModal() + } + + guard response == .OK, let url = savePanel.url else { + phase = .pickDatabase + return + } + + // Show progress immediately so the user gets feedback while we fetch + // the database size estimate and locate pg_dump. + phase = .running(database: database, totalBytes: nil) + + let totalBytes = await PostgresDumpService.estimatedDatabaseSize( + connection: connection, + database: database + ) + + do { + try await service.start( + connection: connection, + database: database, + fileURL: url, + totalBytesEstimate: totalBytes + ) + } catch { + phase = .failed(message: error.localizedDescription) + } + } + + private static func defaultFilename(database: String) -> String { + let timestamp = Self.timestampFormatter.string(from: Date()) + let safeDB = database.isEmpty ? "database" : database + return "\(safeDB)-\(timestamp).dump" + } + + private static let timestampFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd-HHmmss" + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter + }() +} diff --git a/TablePro/Views/Backup/BackupProgressSheet.swift b/TablePro/Views/Backup/BackupProgressSheet.swift new file mode 100644 index 000000000..3c0c42c87 --- /dev/null +++ b/TablePro/Views/Backup/BackupProgressSheet.swift @@ -0,0 +1,169 @@ +// +// BackupProgressSheet.swift +// TablePro +// +// Shared progress sheet for the backup and restore flows. +// + +import SwiftUI + +struct BackupProgressSheet: View { + enum Kind { + case backup + case restore + } + + let kind: Kind + let database: String + /// Bytes processed so far. For `.backup` this is the dump file size on disk. + let bytesWritten: Int64 + /// Upper bound used to render a determinate bar. For backup this is + /// `pg_database_size`, which over-estimates the dump file (compression), + /// so the bar is capped at ~95% until the process exits. `nil` keeps the + /// bar indeterminate (used for restore). + let totalBytes: Int64? + let isCancelling: Bool + let onCancel: () -> Void + + @State private var showCancelConfirmation = false + + var body: some View { + VStack(spacing: 20) { + Text(titleString) + .font(.title3.weight(.semibold)) + + VStack(spacing: 8) { + HStack { + Text(database) + .font(.body) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + if kind == .backup { + Text(byteCountString) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(.secondary) + .monospacedDigit() + } + } + + progressBar + } + + HStack(spacing: 8) { + if isCancelling { + ProgressView().controlSize(.small) + Text("Cancelling\u{2026}") + .font(.callout) + .foregroundStyle(.secondary) + } else { + Button("Cancel") { + showCancelConfirmation = true + } + .frame(width: 100) + } + } + } + .padding(24) + .frame(width: 420) + .background(Color(nsColor: .windowBackgroundColor)) + .interactiveDismissDisabled() + .alert(cancelAlertTitle, isPresented: $showCancelConfirmation) { + Button(keepGoingLabel, role: .cancel) { } + Button(cancelAlertConfirmLabel, role: .destructive) { onCancel() } + } message: { + Text(cancelAlertMessage) + } + } + + @ViewBuilder + private var progressBar: some View { + if let totalBytes, totalBytes > 0 { + ProgressView(value: progressFraction) + .progressViewStyle(.linear) + } else { + ProgressView() + .progressViewStyle(.linear) + } + } + + /// Bytes / totalBytes, capped at 0.95 so the bar doesn't appear "done" while + /// pg_dump is still finalizing the archive trailer. + private var progressFraction: Double { + guard let totalBytes, totalBytes > 0 else { return 0 } + let raw = Double(bytesWritten) / Double(totalBytes) + return min(raw, 0.95) + } + + private var keepGoingLabel: String { + switch kind { + case .backup: return String(localized: "Keep Backing Up") + case .restore: return String(localized: "Keep Restoring") + } + } + + private var titleString: String { + switch kind { + case .backup: return String(localized: "Creating Backup Dump") + case .restore: return String(localized: "Restoring Dump") + } + } + + private var cancelAlertTitle: String { + switch kind { + case .backup: return String(localized: "Cancel Backup Dump?") + case .restore: return String(localized: "Cancel Restore Dump?") + } + } + + private var cancelAlertConfirmLabel: String { + switch kind { + case .backup: return String(localized: "Cancel Backup Dump") + case .restore: return String(localized: "Cancel Restore Dump") + } + } + + private var cancelAlertMessage: String { + switch kind { + case .backup: return String(localized: "The partial backup file will be removed.") + case .restore: return String(localized: "The target database may be left in a partial state.") + } + } + + private var byteCountString: String { + ByteCountFormatter.string(fromByteCount: bytesWritten, countStyle: .file) + } +} + +#Preview("Backup determinate") { + BackupProgressSheet( + kind: .backup, + database: "production", + bytesWritten: 12_345_678, + totalBytes: 50_000_000, + isCancelling: false, + onCancel: {} + ) +} + +#Preview("Backup indeterminate") { + BackupProgressSheet( + kind: .backup, + database: "production", + bytesWritten: 12_345_678, + totalBytes: nil, + isCancelling: false, + onCancel: {} + ) +} + +#Preview("Restore") { + BackupProgressSheet( + kind: .restore, + database: "production", + bytesWritten: 0, + totalBytes: nil, + isCancelling: false, + onCancel: {} + ) +} diff --git a/TablePro/Views/Backup/BackupResultSheet.swift b/TablePro/Views/Backup/BackupResultSheet.swift new file mode 100644 index 000000000..69bcb15c9 --- /dev/null +++ b/TablePro/Views/Backup/BackupResultSheet.swift @@ -0,0 +1,166 @@ +// +// BackupResultSheet.swift +// TablePro +// +// Shared result sheet for the backup and restore flows. +// + +import SwiftUI + +struct BackupResultSheet: View { + enum Kind { + case backup + case restore + } + + enum Outcome { + case backupSuccess(database: String, destination: URL, bytes: Int64) + case restoreSuccess(database: String, source: URL) + case failure(message: String) + case cancelled + } + + let kind: Kind + let outcome: Outcome + let onClose: () -> Void + let onShowInFinder: (() -> Void)? + + var body: some View { + VStack(spacing: 16) { + icon + .font(.system(size: 36)) + .foregroundStyle(tintColor) + + Text(title) + .font(.title3.weight(.semibold)) + .multilineTextAlignment(.center) + + if let detail { + Text(detail) + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .lineLimit(8) + .frame(maxWidth: .infinity, alignment: .center) + .textSelection(.enabled) + } + + HStack(spacing: 12) { + if case .backupSuccess = outcome, let onShowInFinder { + Button(String(localized: "Show in Finder")) { + onShowInFinder() + onClose() + } + } + Button(String(localized: "Done")) { + onClose() + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.return) + } + } + .padding(24) + .frame(width: 420) + .background(Color(nsColor: .windowBackgroundColor)) + } + + @ViewBuilder + private var icon: some View { + switch outcome { + case .backupSuccess, .restoreSuccess: + Image(systemName: "checkmark.circle.fill") + case .failure: + Image(systemName: "exclamationmark.triangle.fill") + case .cancelled: + Image(systemName: "xmark.circle.fill") + } + } + + private var tintColor: Color { + switch outcome { + case .backupSuccess, .restoreSuccess: return Color(nsColor: .systemGreen) + case .failure: return Color(nsColor: .systemOrange) + case .cancelled: return Color(nsColor: .systemGray) + } + } + + private var title: String { + switch outcome { + case .backupSuccess: + return String(localized: "Backup Dump Complete") + case .restoreSuccess: + return String(localized: "Restore Dump Complete") + case .failure: + switch kind { + case .backup: return String(localized: "Backup Dump Failed") + case .restore: return String(localized: "Restore Dump Failed") + } + case .cancelled: + switch kind { + case .backup: return String(localized: "Backup Dump Cancelled") + case .restore: return String(localized: "Restore Dump Cancelled") + } + } + } + + private var detail: String? { + switch outcome { + case .backupSuccess(let database, let destination, let bytes): + let size = ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file) + return String( + format: String(localized: "Saved %@ of \u{201C}%@\u{201D} to %@"), + size, + database, + destination.path + ) + case .restoreSuccess(let database, let source): + return String( + format: String(localized: "Restored \u{201C}%@\u{201D} from %@"), + database, + source.path + ) + 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.") + } + } + } +} + +#Preview("Backup Success") { + BackupResultSheet( + kind: .backup, + outcome: .backupSuccess( + database: "production", + destination: URL(fileURLWithPath: "/Users/me/Desktop/production-2025-05-11-120000.dump"), + bytes: 12_345_678 + ), + onClose: {}, + onShowInFinder: {} + ) +} + +#Preview("Restore Success") { + BackupResultSheet( + kind: .restore, + outcome: .restoreSuccess( + database: "production", + source: URL(fileURLWithPath: "/Users/me/Desktop/production.dump") + ), + onClose: {}, + onShowInFinder: nil + ) +} + +#Preview("Restore Failure") { + BackupResultSheet( + kind: .restore, + outcome: .failure(message: "pg_restore: error: could not connect to database \"missing\": FATAL: database does not exist"), + onClose: {}, + onShowInFinder: nil + ) +} diff --git a/TablePro/Views/Backup/RestoreDatabaseFlow.swift b/TablePro/Views/Backup/RestoreDatabaseFlow.swift new file mode 100644 index 000000000..3073f25e1 --- /dev/null +++ b/TablePro/Views/Backup/RestoreDatabaseFlow.swift @@ -0,0 +1,203 @@ +// +// RestoreDatabaseFlow.swift +// TablePro +// +// Sheet body for the Restore Dump menu item. Opens NSOpenPanel as a +// sub-sheet on appear (symmetric with backup's NSSavePanel), then +// presents the database picker, then drives `PostgresRestoreService`. +// + +import AppKit +import SwiftUI +import UniformTypeIdentifiers + +struct RestoreDatabaseFlow: View { + @Binding var isPresented: Bool + let connection: DatabaseConnection + let initialDatabase: String + + @State private var service = PostgresDumpService(kind: .restore) + @State private var phase: Phase = .needsSource + @State private var sourceURL: URL? + + private enum Phase: Equatable { + case needsSource + case pickDatabase + case running(database: String) + case finished(database: String, source: URL) + case failed(message: String) + case cancelled + } + + var body: some View { + Group { + switch phase { + case .needsSource: + // Placeholder while the open panel is presented as a sub-sheet. + sourceLoading + case .pickDatabase: + pickerView + case .running(let database): + BackupProgressSheet( + kind: .restore, + database: database, + bytesWritten: 0, + totalBytes: nil, + isCancelling: service.state == .cancelling, + onCancel: { service.cancel() } + ) + case .finished(let database, let source): + BackupResultSheet( + kind: .restore, + outcome: .restoreSuccess(database: database, source: source), + onClose: { isPresented = false }, + onShowInFinder: nil + ) + case .failed(let message): + BackupResultSheet( + kind: .restore, + outcome: .failure(message: message), + onClose: { isPresented = false }, + onShowInFinder: nil + ) + case .cancelled: + BackupResultSheet( + kind: .restore, + outcome: .cancelled, + onClose: { isPresented = false }, + onShowInFinder: nil + ) + } + } + .onAppear { + if phase == .needsSource { + Task { await promptForSource() } + } + } + .onChange(of: serviceState) { _, newState in + handleServiceStateChange(newState) + } + } + + private var sourceLoading: some View { + VStack(spacing: 16) { + ProgressView().controlSize(.regular) + Text("Choose a dump file\u{2026}") + .font(.callout) + .foregroundStyle(.secondary) + } + .padding(40) + .frame(width: 420, height: 200) + .background(Color(nsColor: .windowBackgroundColor)) + } + + private var pickerView: some View { + VStack(spacing: 0) { + sourceBanner + Divider() + DatabaseSwitcherSheet( + isPresented: $isPresented, + mode: .restore, + currentDatabase: initialDatabase, + databaseType: connection.type, + connectionId: connection.id, + onSelect: { database in + Task { await startRestore(database: database) } + } + ) + } + } + + @ViewBuilder + private var sourceBanner: some View { + if let url = sourceURL { + HStack(spacing: 8) { + Image(systemName: "doc.zipper") + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + Text("Restore from") + .font(.caption) + .foregroundStyle(.secondary) + Text(url.lastPathComponent) + .font(.body) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer() + Button("Change\u{2026}") { + Task { await promptForSource() } + } + .buttonStyle(.link) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(width: 420, alignment: .leading) + } + } + + /// Hashable snapshot of `service.state` so SwiftUI's `onChange` fires on every transition. + private var serviceState: PostgresDumpState { service.state } + + private func handleServiceStateChange(_ state: PostgresDumpState) { + switch state { + case .running(let database, _, _, _): + phase = .running(database: database) + case .finished(let database, let fileURL, _): + phase = .finished(database: database, source: fileURL) + case .failed(let message): + phase = .failed(message: message) + case .cancelled: + phase = .cancelled + case .idle, .cancelling: + break + } + } + + @MainActor + private func promptForSource() async { + let openPanel = NSOpenPanel() + openPanel.canChooseFiles = true + openPanel.canChooseDirectories = false + openPanel.allowsMultipleSelection = false + openPanel.allowedContentTypes = Self.allowedDumpTypes + openPanel.title = String(localized: "Choose Dump File") + openPanel.prompt = String(localized: "Choose") + openPanel.message = String(localized: "Select a backup file produced by pg_dump in custom archive format (.dump).") + + let window = NSApp.keyWindow + let response: NSApplication.ModalResponse + if let window { + response = await openPanel.beginSheetModal(for: window) + } else { + response = openPanel.runModal() + } + guard response == .OK, let url = openPanel.url else { + // Cancel from the very-first source pick closes the flow; + // cancel from a Change… click leaves the existing source in place. + if sourceURL == nil { isPresented = false } + return + } + sourceURL = url + if phase == .needsSource { phase = .pickDatabase } + } + + private func startRestore(database: String) async { + guard let source = sourceURL else { return } + phase = .running(database: database) + do { + try await service.start(connection: connection, database: database, fileURL: source) + } catch { + phase = .failed(message: error.localizedDescription) + } + } + + /// File types accepted in the open panel. `.dump` is the convention for + /// pg_dump custom archive output but plenty of files have generic extensions. + private static var allowedDumpTypes: [UTType] { + var types: [UTType] = [.data] + if let dumpType = UTType(filenameExtension: "dump") { + types.insert(dumpType, at: 0) + } + return types + } +} diff --git a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift index 6b9b807b1..5282494a1 100644 --- a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift +++ b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift @@ -11,9 +11,26 @@ import SwiftUI import TableProPluginKit struct DatabaseSwitcherSheet: View { + /// What the sheet is being used for. `switch` (default) switches the active + /// database/schema; `backup` picks a database to feed into a backup flow; + /// `restore` picks the target database for a restore flow. + enum Mode { + case `switch` + case backup + case restore + } + + /// Modes that pick a database for an out-of-band flow (backup / restore). + /// These share UI affordances: schemas tab hidden, create/drop hidden, + /// the primary button doesn't auto-dismiss. + private var isHandoffMode: Bool { + mode == .backup || mode == .restore + } + @Binding var isPresented: Bool @Environment(\.dismiss) private var dismiss + let mode: Mode let currentDatabase: String? let currentSchema: String? let databaseType: DatabaseType @@ -41,31 +58,43 @@ struct DatabaseSwitcherSheet: View { } init( - isPresented: Binding, currentDatabase: String?, currentSchema: String? = nil, + isPresented: Binding, + mode: Mode = .switch, + currentDatabase: String?, + currentSchema: String? = nil, databaseType: DatabaseType, - connectionId: UUID, onSelect: @escaping (String) -> Void, + connectionId: UUID, + onSelect: @escaping (String) -> Void, onSelectSchema: ((String) -> Void)? = nil ) { self._isPresented = isPresented + self.mode = mode self.currentDatabase = currentDatabase self.currentSchema = currentSchema self.databaseType = databaseType self.connectionId = connectionId self.onSelect = onSelect self.onSelectSchema = onSelectSchema + // Backup and restore always operate at the database level (pg_dump + // dumps a whole database). Force .database so PostgreSQL doesn't + // open the picker in schema mode. + let initialMode: DatabaseSwitcherViewModel.Mode? = (mode == .backup || mode == .restore) + ? .database + : nil self._viewModel = State( wrappedValue: DatabaseSwitcherViewModel( connectionId: connectionId, currentDatabase: currentDatabase, currentSchema: currentSchema, - databaseType: databaseType + databaseType: databaseType, + initialMode: initialMode )) } var body: some View { VStack(spacing: 0) { - // Databases / Schemas toggle (PostgreSQL only) - if PluginManager.shared.supportsSchemaSwitching(for: databaseType) { + // Databases / Schemas toggle (PostgreSQL only); hidden for handoff flows. + if !isHandoffMode, PluginManager.shared.supportsSchemaSwitching(for: databaseType) { Picker("", selection: $viewModel.mode) { Text(String(localized: "Databases")) .tag(DatabaseSwitcherViewModel.Mode.database) @@ -108,9 +137,7 @@ struct DatabaseSwitcherSheet: View { footer } .frame(width: 420, height: 480) - .navigationTitle(isSchemaMode - ? String(localized: "Open Schema") - : String(localized: "Open Database")) + .navigationTitle(navigationTitleString) .background(Color(nsColor: .windowBackgroundColor)) .task { await viewModel.fetchDatabases() } .task { await refreshCreateSupport() } @@ -173,7 +200,7 @@ struct DatabaseSwitcherSheet: View { .buttonStyle(.borderless) .help(String(localized: "Refresh database list")) - if !isSchemaMode && supportsCreateDatabase { + if !isHandoffMode, !isSchemaMode, supportsCreateDatabase { Button(action: { showCreateDialog = true }) { Image(systemName: "plus") .frame(width: 24, height: 24) @@ -183,7 +210,7 @@ struct DatabaseSwitcherSheet: View { } // Drop - if !isSchemaMode && PluginManager.shared.supportsDropDatabase(for: databaseType) { + if !isHandoffMode, !isSchemaMode, PluginManager.shared.supportsDropDatabase(for: databaseType) { Button(action: { initiateDropForSelected() }) { Image(systemName: "trash") .frame(width: 24, height: 24) @@ -360,6 +387,35 @@ struct DatabaseSwitcherSheet: View { // MARK: - Footer + private var navigationTitleString: String { + switch mode { + case .switch: + return isSchemaMode + ? String(localized: "Open Schema") + : String(localized: "Open Database") + case .backup: + return String(localized: "Backup Dump") + case .restore: + return String(localized: "Restore Dump") + } + } + + private var primaryButtonLabel: String { + switch mode { + case .switch: return String(localized: "Open") + case .backup: return String(localized: "Backup Dump\u{2026}") + case .restore: return String(localized: "Restore Dump\u{2026}") + } + } + + private var primaryButtonDisabled: Bool { + guard let selected = viewModel.selectedDatabase else { return true } + // In switch mode, picking the already-active database/schema is a no-op. + // In backup/restore modes the active database is a valid target. + if mode == .switch, selected == activeName { return true } + return false + } + private var footer: some View { HStack { Button("Cancel") { @@ -368,13 +424,11 @@ struct DatabaseSwitcherSheet: View { Spacer() - Button("Open") { + Button(primaryButtonLabel) { openSelectedDatabase() } .buttonStyle(.borderedProminent) - .disabled( - viewModel.selectedDatabase == nil || viewModel.selectedDatabase == activeName - ) + .disabled(primaryButtonDisabled) .keyboardShortcut(.return, modifiers: []) } .padding(12) @@ -412,6 +466,14 @@ struct DatabaseSwitcherSheet: View { private func openSelectedDatabase() { guard let database = viewModel.selectedDatabase else { return } + // Backup/restore: hand the selection off to the parent flow without + // dismissing. The host sheet stays mounted and transitions to the + // next step (save/open panel, then progress). + if isHandoffMode { + onSelect(database) + return + } + // Don't reopen current database/schema if database == activeName { dismiss() diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 29324a8dd..c86716096 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -688,6 +688,24 @@ final class MainContentCommandActions { coordinator?.openImportDialog() } + func backupDatabase() { + coordinator?.activeSheet = .backupDatabase + } + + /// Backups currently ship for PostgreSQL and Redshift (both use pg_dump). + var supportsBackup: Bool { + connection.type == .postgresql || connection.type == .redshift + } + + /// Restore is offered for the same database types as backup. The actual + /// flow opens NSOpenPanel for the .dump file first, then opens the + /// `restoreDatabase` sheet to pick the target database. + var supportsRestore: Bool { supportsBackup } + + func restoreDatabase() { + coordinator?.activeSheet = .restoreDatabase + } + func saveAsFavorite() { coordinator?.saveCurrentQueryAsFavorite() } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index fb8668322..a29e5cb96 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -46,6 +46,8 @@ enum ActiveSheet: Identifiable { case exportDialog case importDialog case exportQueryResults + case backupDatabase + case restoreDatabase case maintenance(operation: String, tableName: String) var id: String { @@ -54,6 +56,8 @@ enum ActiveSheet: Identifiable { case .exportDialog: "exportDialog" case .importDialog: "importDialog" case .exportQueryResults: "exportQueryResults" + case .backupDatabase: "backupDatabase" + case .restoreDatabase: "restoreDatabase" case .maintenance(let operation, let tableName): "maintenance-\(operation)-\(tableName)" } } diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 953f5e35d..dfadca13d 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -198,6 +198,20 @@ struct MainContentView: View { connection: connection, initialFileURL: coordinator.importFileURL ) + case .backupDatabase: + BackupDatabaseFlow( + isPresented: dismissBinding, + connection: connectionWithCurrentDatabase, + initialDatabase: DatabaseManager.shared.session(for: connection.id)?.currentDatabase + ?? connection.database + ) + case .restoreDatabase: + RestoreDatabaseFlow( + isPresented: dismissBinding, + connection: connectionWithCurrentDatabase, + initialDatabase: DatabaseManager.shared.session(for: connection.id)?.currentDatabase + ?? connection.database + ) case .maintenance(let operation, let tableName): MaintenanceSheet( operation: operation, diff --git a/TablePro/Views/Settings/TerminalSettingsView.swift b/TablePro/Views/Settings/TerminalSettingsView.swift index 412f9cc9f..4b4c256af 100644 --- a/TablePro/Views/Settings/TerminalSettingsView.swift +++ b/TablePro/Views/Settings/TerminalSettingsView.swift @@ -126,6 +126,8 @@ struct TerminalSettingsView: View { ForEach(Self.terminalDatabaseTypes, id: \.rawValue) { dbType in cliPathRow(for: dbType) } + postgresToolRow(key: TerminalSettings.pgDumpCliPathKey, binaryName: "pg_dump") + postgresToolRow(key: TerminalSettings.pgRestoreCliPathKey, binaryName: "pg_restore") } } footer: { Text("Override auto-detected CLI paths per database type.") @@ -147,8 +149,22 @@ struct TerminalSettingsView: View { TextField(dbType.displayName, text: binding, prompt: Text(resolved)) } + @ViewBuilder + private func postgresToolRow(key: String, binaryName: String) -> some View { + let binding = Binding( + get: { settings.cliPaths[key] ?? "" }, + set: { settings.cliPaths[key] = $0.isEmpty ? nil : $0 } + ) + let resolved = resolvedPaths[key] ?? binaryName + TextField(binaryName, text: binding, prompt: Text(resolved)) + } + private func resolveAllCliPaths() async { let dbTypes = Self.terminalDatabaseTypes + let postgresTools: [(key: String, binary: String)] = [ + (TerminalSettings.pgDumpCliPathKey, "pg_dump"), + (TerminalSettings.pgRestoreCliPathKey, "pg_restore") + ] let results = await withTaskGroup(of: (String, String).self) { group in for dbType in dbTypes { group.addTask { @@ -159,6 +175,14 @@ struct TerminalSettingsView: View { return (dbType.rawValue, resolved ?? name) } } + for tool in postgresTools { + group.addTask { + let resolved = await Task.detached(priority: .utility) { + CLICommandResolver.findExecutable(tool.binary) + }.value + return (tool.key, resolved ?? tool.binary) + } + } var paths: [String: String] = [:] for await (key, value) in group { paths[key] = value diff --git a/TableProTests/Database/PostgresDumpServiceTests.swift b/TableProTests/Database/PostgresDumpServiceTests.swift new file mode 100644 index 000000000..bf7cb71c0 --- /dev/null +++ b/TableProTests/Database/PostgresDumpServiceTests.swift @@ -0,0 +1,319 @@ +// +// PostgresDumpServiceTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("PostgresDumpService command construction") +struct PostgresDumpServiceCommandTests { + private func connection( + host: String = "db.example.com", + port: Int = 5_432, + username: String = "alice", + sslMode: SSLMode = .disabled + ) -> DatabaseConnection { + var sslConfig = SSLConfiguration() + sslConfig.mode = sslMode + return DatabaseConnection( + name: "Test", + host: host, + port: port, + database: "sales", + username: username, + type: .postgresql, + sshConfig: SSHConfiguration(), + sslConfig: sslConfig + ) + } + + @Test("backup command sets -Fc, host, port, username, -d, -f") + func backupCommandShape() { + let command = PostgresDumpService.buildCommand( + kind: .backup, + executable: URL(fileURLWithPath: "/usr/bin/pg_dump"), + effective: connection(), + database: "sales", + fileURL: URL(fileURLWithPath: "/tmp/sales.dump"), + password: "s3cret" + ) + + #expect(command.arguments.contains("-Fc")) + #expect(command.arguments.contains("--no-password")) + #expect(slice(after: "-h", in: command.arguments) == "db.example.com") + #expect(slice(after: "-p", in: command.arguments) == "5432") + #expect(slice(after: "-U", in: command.arguments) == "alice") + #expect(slice(after: "-d", in: command.arguments) == "sales") + #expect(slice(after: "-f", in: command.arguments) == "/tmp/sales.dump") + #expect(command.environment["PGPASSWORD"] == "s3cret") + } + + @Test("restore command sets --no-owner, --no-acl, -d, positional path") + func restoreCommandShape() { + let command = PostgresDumpService.buildCommand( + kind: .restore, + executable: URL(fileURLWithPath: "/usr/bin/pg_restore"), + effective: connection(), + database: "sales", + fileURL: URL(fileURLWithPath: "/tmp/sales.dump"), + password: "s3cret" + ) + + #expect(command.arguments.contains("--no-owner")) + #expect(command.arguments.contains("--no-acl")) + #expect(command.arguments.contains("--no-password")) + #expect(!command.arguments.contains("-Fc")) + #expect(slice(after: "-d", in: command.arguments) == "sales") + #expect(command.arguments.last == "/tmp/sales.dump") + #expect(!command.arguments.contains("-f")) + } + + @Test("empty host falls back to 127.0.0.1") + func hostFallback() { + let command = PostgresDumpService.buildCommand( + kind: .backup, + executable: URL(fileURLWithPath: "/usr/bin/pg_dump"), + effective: connection(host: ""), + database: "sales", + fileURL: URL(fileURLWithPath: "/tmp/x.dump"), + password: nil + ) + #expect(slice(after: "-h", in: command.arguments) == "127.0.0.1") + } + + @Test("empty username omits -U entirely") + func usernameOmitted() { + let command = PostgresDumpService.buildCommand( + kind: .backup, + executable: URL(fileURLWithPath: "/usr/bin/pg_dump"), + effective: connection(username: ""), + database: "sales", + fileURL: URL(fileURLWithPath: "/tmp/x.dump"), + password: nil + ) + #expect(!command.arguments.contains("-U")) + } + + @Test("nil/empty password does not set PGPASSWORD") + func passwordOptional() { + let nilPw = PostgresDumpService.buildCommand( + kind: .backup, + executable: URL(fileURLWithPath: "/usr/bin/pg_dump"), + effective: connection(), + database: "sales", + fileURL: URL(fileURLWithPath: "/tmp/x.dump"), + password: nil + ) + let emptyPw = PostgresDumpService.buildCommand( + kind: .backup, + executable: URL(fileURLWithPath: "/usr/bin/pg_dump"), + effective: connection(), + database: "sales", + fileURL: URL(fileURLWithPath: "/tmp/x.dump"), + password: "" + ) + #expect(nilPw.environment["PGPASSWORD"] == nil) + #expect(emptyPw.environment["PGPASSWORD"] == nil) + } + + @Test("SSL mode maps to libpq PGSSLMODE values", arguments: [ + (SSLMode.disabled, nil as String?), + (SSLMode.preferred, "prefer"), + (SSLMode.required, "require"), + (SSLMode.verifyCa, "verify-ca"), + (SSLMode.verifyIdentity, "verify-full") + ]) + func sslModeMapping(mode: SSLMode, expected: String?) { + let command = PostgresDumpService.buildCommand( + kind: .backup, + executable: URL(fileURLWithPath: "/usr/bin/pg_dump"), + effective: connection(sslMode: mode), + database: "sales", + fileURL: URL(fileURLWithPath: "/tmp/x.dump"), + password: nil + ) + #expect(command.environment["PGSSLMODE"] == expected) + } + + /// Returns the argument immediately following `flag` in the arg list. + private func slice(after flag: String, in args: [String]) -> String? { + guard let index = args.firstIndex(of: flag), index + 1 < args.count else { return nil } + return args[index + 1] + } +} + +// MARK: - Fake Runner + +/// Test double for `PostgresDumpRunner` that lets tests drive the result. +private final class FakeDumpRunner: PostgresDumpRunner, @unchecked Sendable { + private(set) var startedCommand: PostgresDumpCommand? + private(set) var cancelCount: Int = 0 + private var continuation: CheckedContinuation? + private let lock = NSLock() + + func start(_ command: PostgresDumpCommand) throws { + startedCommand = command + } + + func cancel() { + lock.lock() + cancelCount += 1 + lock.unlock() + } + + var result: PostgresDumpRunResult { + get async { + await withCheckedContinuation { continuation in + self.lock.lock() + self.continuation = continuation + self.lock.unlock() + } + } + } + + /// Test driver: resolves the pending `result` await with the given outcome. + func finish(_ outcome: PostgresDumpRunResult) { + lock.lock() + let continuation = self.continuation + self.continuation = nil + lock.unlock() + continuation?.resume(returning: outcome) + } +} + +@Suite("PostgresDumpService state machine", .serialized) +@MainActor +struct PostgresDumpServiceStateMachineTests { + private func fakeCommand() -> PostgresDumpCommand { + PostgresDumpCommand( + executable: URL(fileURLWithPath: "/usr/bin/true"), + arguments: [], + environment: [:], + stderrByteCap: 64_000 + ) + } + + @Test("successful run transitions idle -> running -> finished") + func successfulBackup() async throws { + let runner = FakeDumpRunner() + let service = PostgresDumpService(kind: .backup, runnerFactory: { runner }) + + #expect(service.state == .idle) + try service.run( + command: fakeCommand(), + database: "sales", + fileURL: URL(fileURLWithPath: "/tmp/test-success.dump"), + totalBytesEstimate: 1_000 + ) + + // Now running + if case .running(let db, _, _, let total) = service.state { + #expect(db == "sales") + #expect(total == 1_000) + } else { + Issue.record("expected running, got \(service.state)") + } + + runner.finish(.init(exitCode: 0, stderr: "", wasCancelled: false)) + try await waitFor { if case .finished = service.state { return true }; return false } + + if case .finished(let db, _, _) = service.state { + #expect(db == "sales") + } else { + Issue.record("expected finished, got \(service.state)") + } + } + + @Test("non-zero exit transitions to failed and surfaces stderr") + func failedRun() async throws { + let runner = FakeDumpRunner() + let service = PostgresDumpService(kind: .restore, runnerFactory: { runner }) + + try service.run( + command: fakeCommand(), + database: "sales", + fileURL: URL(fileURLWithPath: "/tmp/test-failed.dump") + ) + + runner.finish(.init(exitCode: 1, stderr: "FATAL: connection refused", wasCancelled: false)) + try await waitFor { if case .failed = service.state { return true }; return false } + + if case .failed(let message) = service.state { + #expect(message == "FATAL: connection refused") + } else { + Issue.record("expected failed, got \(service.state)") + } + } + + @Test("cancel transitions running -> cancelling -> cancelled") + func cancelRun() async throws { + let runner = FakeDumpRunner() + let service = PostgresDumpService(kind: .backup, runnerFactory: { runner }) + + try service.run( + command: fakeCommand(), + database: "sales", + fileURL: URL(fileURLWithPath: "/tmp/test-cancel.dump") + ) + + service.cancel() + #expect(service.state == .cancelling) + #expect(runner.cancelCount == 1) + + runner.finish(.init(exitCode: -15, stderr: "", wasCancelled: true)) + try await waitFor { service.state == .cancelled } + #expect(service.state == .cancelled) + } + + @Test("calling run while already running throws alreadyRunning") + func doubleRunThrows() throws { + let runner = FakeDumpRunner() + let service = PostgresDumpService(kind: .backup, runnerFactory: { runner }) + + try service.run( + command: fakeCommand(), + database: "sales", + fileURL: URL(fileURLWithPath: "/tmp/test-double.dump") + ) + + #expect(throws: PostgresDumpError.alreadyRunning) { + try service.run( + command: fakeCommand(), + database: "sales", + fileURL: URL(fileURLWithPath: "/tmp/test-double-2.dump") + ) + } + } + + @Test("empty stderr falls back to a synthesized error message") + func emptyStderrFallback() async throws { + let runner = FakeDumpRunner() + let service = PostgresDumpService(kind: .backup, runnerFactory: { runner }) + + try service.run( + command: fakeCommand(), + database: "sales", + fileURL: URL(fileURLWithPath: "/tmp/test-emptyerr.dump") + ) + runner.finish(.init(exitCode: 42, stderr: "", wasCancelled: false)) + try await waitFor { if case .failed = service.state { return true }; return false } + + if case .failed(let message) = service.state { + #expect(message.contains("42")) + } else { + Issue.record("expected failed, got \(service.state)") + } + } + + /// Polls `condition` every 10ms up to 2 seconds. + private func waitFor(_ condition: @MainActor @Sendable () -> Bool) async throws { + for _ in 0..<200 { + if condition() { return } + try await Task.sleep(nanoseconds: 10_000_000) + } + Issue.record("timed out waiting for condition") + } +}