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/PostgresBackupService.swift b/TablePro/Core/Database/PostgresBackupService.swift new file mode 100644 index 000000000..d4300c6ee --- /dev/null +++ b/TablePro/Core/Database/PostgresBackupService.swift @@ -0,0 +1,207 @@ +// +// PostgresBackupService.swift +// TablePro +// + +import Foundation +import Observation +import os + +@MainActor +@Observable +final class PostgresBackupService { + nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "PostgresBackupService") + + enum State: Equatable { + case idle + case running(database: String, bytesWritten: Int64) + case cancelling + case finished(database: String, destination: URL, bytesWritten: Int64) + case failed(message: String) + case cancelled + } + + enum BackupError: LocalizedError { + case pgDumpNotFound + case unsupportedDatabase + case noSession + case alreadyRunning + + var errorDescription: String? { + switch self { + case .pgDumpNotFound: + return String(localized: """ + 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. + """) + case .unsupportedDatabase: + return String(localized: "Backups are only supported for PostgreSQL and Redshift connections.") + case .noSession: + return String(localized: "Connect to the database before starting a backup.") + case .alreadyRunning: + return String(localized: "A backup is already running.") + } + } + } + + private(set) var state: State = .idle + + @ObservationIgnored private var process: Process? + @ObservationIgnored private var stderrBuffer = Data() + @ObservationIgnored private var byteSizeTask: Task? + + /// Returns the resolved pg_dump executable path, honoring the user-configured override. + nonisolated static func resolvePgDumpPath(customPath: String?) -> String? { + CLICommandResolver.findExecutable("pg_dump", customPath: customPath) + } + + private 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" + } + } + + func start(connection: DatabaseConnection, database: String, destination: URL) async throws { + if case .running = state { throw BackupError.alreadyRunning } + if case .cancelling = state { throw BackupError.alreadyRunning } + + guard connection.type == .postgresql || connection.type == .redshift else { + throw BackupError.unsupportedDatabase + } + + let session = DatabaseManager.shared.session(for: connection.id) + guard session?.isConnected == true else { + throw BackupError.noSession + } + + let effective = session?.effectiveConnection ?? connection + + let customPath = AppSettingsManager.shared.terminal.cliPaths[TerminalSettings.pgDumpCliPathKey]?.nilIfEmpty + guard let pgDumpPath = Self.resolvePgDumpPath(customPath: customPath) else { + throw BackupError.pgDumpNotFound + } + + let password = ConnectionStorage.shared.loadPassword(for: connection.id) ?? session?.cachedPassword + + let proc = Process() + proc.executableURL = URL(fileURLWithPath: pgDumpPath) + + var args: [String] = [ + "-Fc", + "--no-password", + "-h", effective.host.isEmpty ? "127.0.0.1" : effective.host, + "-p", String(effective.port), + "-d", database, + "-f", destination.path + ] + if !effective.username.isEmpty { + args.append(contentsOf: ["-U", effective.username]) + } + proc.arguments = args + + var env = ProcessInfo.processInfo.environment + if let password, !password.isEmpty { + env["PGPASSWORD"] = password + } + if effective.sslConfig.isEnabled { + env["PGSSLMODE"] = Self.pgSSLMode(effective.sslConfig.mode) + } + proc.environment = env + + let stderrPipe = Pipe() + proc.standardError = stderrPipe + proc.standardOutput = FileHandle.nullDevice + + stderrBuffer = Data() + stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let chunk = handle.availableData + guard !chunk.isEmpty else { return } + Task { @MainActor [weak self] in + self?.stderrBuffer.append(chunk) + if self?.stderrBuffer.count ?? 0 > 64_000 { + let trimmed = self?.stderrBuffer.suffix(64_000) ?? Data() + self?.stderrBuffer = Data(trimmed) + } + } + } + + let dbName = database + proc.terminationHandler = { [weak self] terminated in + stderrPipe.fileHandleForReading.readabilityHandler = nil + Task { @MainActor [weak self] in + self?.handleTermination(terminated, database: dbName, destination: destination) + } + } + + do { + try proc.run() + } catch { + throw BackupError.pgDumpNotFound + } + + self.process = proc + state = .running(database: database, bytesWritten: 0) + startByteSizePolling(url: destination, database: database) + + Self.logger.info("pg_dump started pid=\(proc.processIdentifier, privacy: .public) db=\(dbName, privacy: .public)") + } + + func cancel() { + guard case .running = state, let proc = process else { return } + state = .cancelling + proc.terminate() + } + + private func handleTermination(_ proc: Process, database: String, destination: URL) { + byteSizeTask?.cancel() + byteSizeTask = nil + process = nil + + let exitCode = proc.terminationStatus + let bytes = (try? FileManager.default.attributesOfItem(atPath: destination.path)[.size] as? Int64) ?? 0 + + if exitCode == 0 { + state = .finished(database: database, destination: destination, bytesWritten: bytes) + Self.logger.info("pg_dump finished bytes=\(bytes) db=\(database, privacy: .public)") + return + } + + if case .cancelling = state { + try? FileManager.default.removeItem(at: destination) + state = .cancelled + Self.logger.notice("pg_dump cancelled db=\(database, privacy: .public)") + return + } + + try? FileManager.default.removeItem(at: destination) + let stderrText = String(data: stderrBuffer, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let summary = stderrText.isEmpty + ? String(format: String(localized: "pg_dump exited with code %d"), Int(exitCode)) + : stderrText + state = .failed(message: summary) + Self.logger.error("pg_dump failed code=\(exitCode) db=\(database, privacy: .public) stderr=\(stderrText, privacy: .public)") + } + + private func startByteSizePolling(url: URL, database: String) { + 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, bytesWritten: size) + } + } + } +} + +private extension String { + var nilIfEmpty: String? { isEmpty ? nil : self } +} diff --git a/TablePro/Core/Database/PostgresRestoreService.swift b/TablePro/Core/Database/PostgresRestoreService.swift new file mode 100644 index 000000000..5bee46adf --- /dev/null +++ b/TablePro/Core/Database/PostgresRestoreService.swift @@ -0,0 +1,195 @@ +// +// PostgresRestoreService.swift +// TablePro +// + +import Foundation +import Observation +import os + +@MainActor +@Observable +final class PostgresRestoreService { + nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "PostgresRestoreService") + + enum State: Equatable { + case idle + case running(database: String, source: URL) + case cancelling + case finished(database: String, source: URL) + case failed(message: String) + case cancelled + } + + enum RestoreError: LocalizedError { + case pgRestoreNotFound + case unsupportedDatabase + case noSession + case alreadyRunning + case sourceUnreadable + + var errorDescription: String? { + switch self { + case .pgRestoreNotFound: + return String(localized: """ + 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. + """) + case .unsupportedDatabase: + return String(localized: "Restore is only supported for PostgreSQL and Redshift connections.") + case .noSession: + return String(localized: "Connect to the database before starting a restore.") + case .alreadyRunning: + return String(localized: "A restore is already running.") + case .sourceUnreadable: + return String(localized: "The selected backup file is not readable.") + } + } + } + + private(set) var state: State = .idle + + @ObservationIgnored private var process: Process? + @ObservationIgnored private var stderrBuffer = Data() + + /// Returns the resolved pg_restore executable path, honoring the user-configured override. + nonisolated static func resolvePgRestorePath(customPath: String?) -> String? { + CLICommandResolver.findExecutable("pg_restore", customPath: customPath) + } + + private 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" + } + } + + func start(connection: DatabaseConnection, database: String, source: URL) async throws { + if case .running = state { throw RestoreError.alreadyRunning } + if case .cancelling = state { throw RestoreError.alreadyRunning } + + guard connection.type == .postgresql || connection.type == .redshift else { + throw RestoreError.unsupportedDatabase + } + guard FileManager.default.isReadableFile(atPath: source.path) else { + throw RestoreError.sourceUnreadable + } + + let session = DatabaseManager.shared.session(for: connection.id) + guard session?.isConnected == true else { + throw RestoreError.noSession + } + + let effective = session?.effectiveConnection ?? connection + + let customPath = AppSettingsManager.shared.terminal + .cliPaths[TerminalSettings.pgRestoreCliPathKey]?.nilIfEmpty + guard let pgRestorePath = Self.resolvePgRestorePath(customPath: customPath) else { + throw RestoreError.pgRestoreNotFound + } + + let password = ConnectionStorage.shared.loadPassword(for: connection.id) ?? session?.cachedPassword + + let proc = Process() + proc.executableURL = URL(fileURLWithPath: pgRestorePath) + + var args: [String] = [ + "--no-password", + "--no-owner", + "--no-acl", + "-h", effective.host.isEmpty ? "127.0.0.1" : effective.host, + "-p", String(effective.port), + "-d", database + ] + if !effective.username.isEmpty { + args.append(contentsOf: ["-U", effective.username]) + } + args.append(source.path) + proc.arguments = args + + var env = ProcessInfo.processInfo.environment + if let password, !password.isEmpty { + env["PGPASSWORD"] = password + } + if effective.sslConfig.isEnabled { + env["PGSSLMODE"] = Self.pgSSLMode(effective.sslConfig.mode) + } + proc.environment = env + + let stderrPipe = Pipe() + proc.standardError = stderrPipe + proc.standardOutput = FileHandle.nullDevice + + stderrBuffer = Data() + stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let chunk = handle.availableData + guard !chunk.isEmpty else { return } + Task { @MainActor [weak self] in + self?.stderrBuffer.append(chunk) + if self?.stderrBuffer.count ?? 0 > 64_000 { + let trimmed = self?.stderrBuffer.suffix(64_000) ?? Data() + self?.stderrBuffer = Data(trimmed) + } + } + } + + let dbName = database + let sourceURL = source + proc.terminationHandler = { [weak self] terminated in + stderrPipe.fileHandleForReading.readabilityHandler = nil + Task { @MainActor [weak self] in + self?.handleTermination(terminated, database: dbName, source: sourceURL) + } + } + + do { + try proc.run() + } catch { + throw RestoreError.pgRestoreNotFound + } + + self.process = proc + state = .running(database: database, source: source) + + Self.logger.info("pg_restore started pid=\(proc.processIdentifier, privacy: .public) db=\(dbName, privacy: .public)") + } + + func cancel() { + guard case .running = state, let proc = process else { return } + state = .cancelling + proc.terminate() + } + + private func handleTermination(_ proc: Process, database: String, source: URL) { + process = nil + + let exitCode = proc.terminationStatus + + if case .cancelling = state { + state = .cancelled + Self.logger.notice("pg_restore cancelled db=\(database, privacy: .public)") + return + } + + if exitCode == 0 { + state = .finished(database: database, source: source) + Self.logger.info("pg_restore finished db=\(database, privacy: .public)") + return + } + + let stderrText = String(data: stderrBuffer, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let summary = stderrText.isEmpty + ? String(format: String(localized: "pg_restore exited with code %d"), Int(exitCode)) + : stderrText + state = .failed(message: summary) + Self.logger.error("pg_restore failed code=\(exitCode) db=\(database, privacy: .public) stderr=\(stderrText, privacy: .public)") + } +} + +private extension String { + var nilIfEmpty: String? { isEmpty ? nil : self } +} 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/TableProApp.swift b/TablePro/TableProApp.swift index c7cd4077a..cdf0d825c 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...")) { + actions?.backupDatabase() + } + .disabled(!(actions?.isConnected ?? false) || !(actions?.supportsBackup ?? false)) + + Button(String(localized: "Restore...")) { + actions?.restoreDatabase() + } + .disabled( + !(actions?.isConnected ?? false) + || !(actions?.supportsRestore ?? false) + || actions?.isReadOnly ?? false + ) } // Query menu diff --git a/TablePro/Views/Backup/BackupDatabaseFlow.swift b/TablePro/Views/Backup/BackupDatabaseFlow.swift new file mode 100644 index 000000000..d0b4350e3 --- /dev/null +++ b/TablePro/Views/Backup/BackupDatabaseFlow.swift @@ -0,0 +1,155 @@ +// +// BackupDatabaseFlow.swift +// TablePro +// +// Top-level sheet for the Backup Database menu item. Reuses +// `DatabaseSwitcherSheet` in `.backup` mode to pick the database, +// then drives a NSSavePanel and the `PostgresBackupService` 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 = PostgresBackupService() + @State private var phase: Phase + + private enum Phase: Equatable { + case pickDatabase + case running(database: String) + case finished(database: String, destination: URL, bytes: Int64) + case failed(message: String) + case cancelled + } + + init(isPresented: Binding, connection: DatabaseConnection, initialDatabase: String) { + self._isPresented = isPresented + self.connection = connection + self.initialDatabase = initialDatabase + self._phase = State(initialValue: .pickDatabase) + } + + var body: some View { + Group { + switch phase { + case .pickDatabase: + pickerView + case .running(let database): + BackupProgressSheet( + kind: .backup, + database: database, + bytesWritten: bytesWritten, + 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: PostgresBackupService.State { service.state } + + private func handleServiceStateChange(_ state: PostgresBackupService.State) { + switch state { + case .running(let database, _): + phase = .running(database: database) + case .finished(let database, let destination, let bytes): + phase = .finished(database: database, destination: destination, 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 Backup") + savePanel.message = String(format: String(localized: "Choose where to save the backup 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 + } + + do { + try await service.start(connection: connection, database: database, destination: url) + } 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..2875aca29 --- /dev/null +++ b/TablePro/Views/Backup/BackupProgressSheet.swift @@ -0,0 +1,126 @@ +// +// 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 + /// Number of bytes written so far. Only shown for `.backup`; ignored for `.restore`. + let bytesWritten: 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() + } + } + + ProgressView() + .progressViewStyle(.linear) + } + + 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(String(localized: "Continue"), role: .cancel) { } + Button(cancelAlertConfirmLabel, role: .destructive) { onCancel() } + } message: { + Text(cancelAlertMessage) + } + } + + private var titleString: String { + switch kind { + case .backup: return String(localized: "Backing Up Database") + case .restore: return String(localized: "Restoring Database") + } + } + + private var cancelAlertTitle: String { + switch kind { + case .backup: return String(localized: "Cancel Backup?") + case .restore: return String(localized: "Cancel Restore?") + } + } + + private var cancelAlertConfirmLabel: String { + switch kind { + case .backup: return String(localized: "Cancel Backup") + case .restore: return String(localized: "Cancel Restore") + } + } + + 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") { + BackupProgressSheet( + kind: .backup, + database: "production", + bytesWritten: 12_345_678, + isCancelling: false, + onCancel: {} + ) +} + +#Preview("Restore") { + BackupProgressSheet( + kind: .restore, + database: "production", + bytesWritten: 0, + isCancelling: false, + onCancel: {} + ) +} diff --git a/TablePro/Views/Backup/BackupResultSheet.swift b/TablePro/Views/Backup/BackupResultSheet.swift new file mode 100644 index 000000000..f44dbf49b --- /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: .leading) + .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 Complete") + case .restoreSuccess: + return String(localized: "Restore Complete") + case .failure: + switch kind { + case .backup: return String(localized: "Backup Failed") + case .restore: return String(localized: "Restore Failed") + } + case .cancelled: + switch kind { + case .backup: return String(localized: "Backup Cancelled") + case .restore: return String(localized: "Restore 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..a43677fc5 --- /dev/null +++ b/TablePro/Views/Backup/RestoreDatabaseFlow.swift @@ -0,0 +1,134 @@ +// +// RestoreDatabaseFlow.swift +// TablePro +// +// Sheet body for the Restore Database menu item. The .dump file is +// chosen up front (NSOpenPanel from MainContentCommandActions), so this +// flow opens directly on `DatabaseSwitcherSheet` in `.restore` mode and +// drives `PostgresRestoreService` through to a result. +// + +import AppKit +import SwiftUI + +struct RestoreDatabaseFlow: View { + @Binding var isPresented: Bool + let connection: DatabaseConnection + let initialDatabase: String + let sourceURL: URL + + @State private var service = PostgresRestoreService() + @State private var phase: Phase = .pickDatabase + + private enum Phase: Equatable { + 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 .pickDatabase: + pickerView + case .running(let database): + BackupProgressSheet( + kind: .restore, + database: database, + bytesWritten: 0, + 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 + ) + } + } + .onChange(of: serviceState) { _, newState in + handleServiceStateChange(newState) + } + } + + 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) } + } + ) + } + } + + private var sourceBanner: some View { + HStack(spacing: 8) { + Image(systemName: "doc.zipper") + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + Text("Restore from") + .font(.caption) + .foregroundStyle(.secondary) + Text(sourceURL.lastPathComponent) + .font(.body) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer() + } + .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: PostgresRestoreService.State { service.state } + + private func handleServiceStateChange(_ state: PostgresRestoreService.State) { + switch state { + case .running(let database, _): + phase = .running(database: database) + case .finished(let database, let source): + phase = .finished(database: database, source: source) + case .failed(let message): + phase = .failed(message: message) + case .cancelled: + phase = .cancelled + case .idle, .cancelling: + break + } + } + + private func startRestore(database: String) async { + do { + try await service.start(connection: connection, database: database, source: sourceURL) + } catch { + phase = .failed(message: error.localizedDescription) + } + } +} diff --git a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift index 6b9b807b1..d6c5ce166 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,12 +58,17 @@ 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 @@ -64,8 +86,8 @@ struct DatabaseSwitcherSheet: View { 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 +130,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 +193,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 +203,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 +380,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: "Back Up Database") + case .restore: + return String(localized: "Restore Database") + } + } + + private var primaryButtonLabel: String { + switch mode { + case .switch: return String(localized: "Open") + case .backup: return String(localized: "Back Up\u{2026}") + case .restore: return String(localized: "Restore\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 +417,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 +459,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..9624e7149 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -688,6 +688,41 @@ 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() { + Task { @MainActor in + let openPanel = NSOpenPanel() + openPanel.canChooseFiles = true + openPanel.canChooseDirectories = false + openPanel.allowsMultipleSelection = false + openPanel.title = String(localized: "Choose Backup File") + openPanel.prompt = String(localized: "Choose") + openPanel.message = String(localized: "Select a backup file created with pg_dump custom or directory format.") + + let response: NSApplication.ModalResponse + if let window = self.window { + response = await openPanel.beginSheetModal(for: window) + } else { + response = openPanel.runModal() + } + guard response == .OK, let url = openPanel.url else { return } + coordinator?.activeSheet = .restoreDatabase(fileURL: url) + } + } + func saveAsFavorite() { coordinator?.saveCurrentQueryAsFavorite() } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index fb8668322..9075fc7ff 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(fileURL: URL) 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(let fileURL): "restoreDatabase-\(fileURL.path)" 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..c39e818c3 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -198,6 +198,21 @@ 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(let fileURL): + RestoreDatabaseFlow( + isPresented: dismissBinding, + connection: connectionWithCurrentDatabase, + initialDatabase: DatabaseManager.shared.session(for: connection.id)?.currentDatabase + ?? connection.database, + sourceURL: fileURL + ) 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..0c1535198 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,20 @@ struct TerminalSettingsView: View { TextField(dbType.displayName, text: binding, prompt: Text(resolved)) } + @ViewBuilder + private var pgDumpPathRow: some View { + let key = TerminalSettings.pgDumpCliPathKey + let binding = Binding( + get: { settings.cliPaths[key] ?? "" }, + set: { settings.cliPaths[key] = $0.isEmpty ? nil : $0 } + ) + let resolved = resolvedPaths[key] ?? "pg_dump" + TextField("pg_dump", text: binding, prompt: Text(resolved)) + } + private func resolveAllCliPaths() async { let dbTypes = Self.terminalDatabaseTypes + let pgDumpKey = TerminalSettings.pgDumpCliPathKey let results = await withTaskGroup(of: (String, String).self) { group in for dbType in dbTypes { group.addTask { @@ -159,6 +173,12 @@ struct TerminalSettingsView: View { return (dbType.rawValue, resolved ?? name) } } + group.addTask { + let resolved = await Task.detached(priority: .utility) { + CLICommandResolver.findExecutable("pg_dump") + }.value + return (pgDumpKey, resolved ?? "pg_dump") + } var paths: [String: String] = [:] for await (key, value) in group { paths[key] = value