From cc1cb3411d3213bb3be0c400140bf1b388894b75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 13 May 2026 10:03:59 +0700 Subject: [PATCH 01/15] fix(plugin-postgresql): close continuation race in dump runner --- .../Core/Database/PostgresDumpService.swift | 42 +++++++++---------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/TablePro/Core/Database/PostgresDumpService.swift b/TablePro/Core/Database/PostgresDumpService.swift index e9a225d4a..4ede3ffa5 100644 --- a/TablePro/Core/Database/PostgresDumpService.swift +++ b/TablePro/Core/Database/PostgresDumpService.swift @@ -334,17 +334,14 @@ private extension String { // 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 let stateLock = NSLock() private var stderrBuffer = Data() private var stderrCap = 64_000 private var wasCancelled = false + private var terminationResult: PostgresDumpRunResult? private var continuation: CheckedContinuation? func start(_ command: PostgresDumpCommand) throws { @@ -359,37 +356,41 @@ final class ProcessPostgresDumpRunner: PostgresDumpRunner { stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in let chunk = handle.availableData guard !chunk.isEmpty, let self else { return } - self.bufferLock.lock() + self.stateLock.lock() self.stderrBuffer.append(chunk) if self.stderrBuffer.count > self.stderrCap { self.stderrBuffer = Data(self.stderrBuffer.suffix(self.stderrCap)) } - self.bufferLock.unlock() + self.stateLock.unlock() } process.terminationHandler = { [weak self] proc in guard let self else { return } self.stderrPipe.fileHandleForReading.readabilityHandler = nil - self.bufferLock.lock() + self.stateLock.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.terminationResult = result + let pending = self.continuation self.continuation = nil + self.stateLock.unlock() + + pending?.resume(returning: result) } try process.run() } func cancel() { + stateLock.lock() wasCancelled = true + stateLock.unlock() if process.isRunning { process.terminate() } @@ -398,19 +399,14 @@ final class ProcessPostgresDumpRunner: PostgresDumpRunner { 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 + stateLock.lock() + if let cached = terminationResult { + stateLock.unlock() + continuation.resume(returning: cached) + return } + self.continuation = continuation + stateLock.unlock() } } } From 9bed148dbb70fa962c2d94f794c45ee895285a3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 13 May 2026 10:07:05 +0700 Subject: [PATCH 02/15] fix(changelog): move backup entries to Unreleased and match menu naming --- CHANGELOG.md | 20 ++----- TablePro/Resources/Localizable.xcstrings | 53 ------------------- TablePro/TableProApp.swift | 4 +- .../Views/Backup/RestoreDatabaseFlow.swift | 2 +- 4 files changed, 7 insertions(+), 72 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5636afb1..bbdf10d8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- File > Backup Dump… and Restore Dump… for PostgreSQL and Redshift connections, running `pg_dump -Fc` and `pg_restore --no-owner --no-acl` with live progress, cancel, SSH tunnel reuse, and custom binary paths under Settings > Terminal > CLI Paths (#1211). + ## [0.40.3] - 2026-05-13 ### Fixed @@ -66,22 +70,6 @@ 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 -- iOS: VoiceOver "Delete row" / "Delete group" / "Delete tag" custom actions on rows whose only deletion path was a swipe gesture -- iOS: empty Groups and Tags screens show a Create button so the action is reachable without opening the toolbar -- iOS: "No Results" empty state in Query Editor explains the query returned no rows -- iOS: iCloud sync runs every 30 minutes in the background via `BGAppRefreshTask` while the app is closed (gated by the iCloud Sync setting); iOS schedules the actual cadence based on usage and battery -- iOS: Cmd+F focuses the search field in Tables and Data Browser (iPad keyboard canonical) -- iOS: search text in Tables and Data Browser persists across process kill via `@SceneStorage` (per-window on iPad) -- iOS Settings: iCloud Sync toggle (off keeps connections, groups, and tags on this device only and disables the sync toolbar button), Rows per Page picker (50/100/200/500, applied to new data browser sessions), Default Safe Mode picker (applied when adding a new connection), "Hide query in Live Activities" toggle that swaps the SQL preview for a generic "Running query" label on the lock screen and Dynamic Island -- iOS: alert when the active connection is deleted mid-session (for example via iCloud sync from another device), so a stale screen no longer fails silently on the next action -- iOS: Face ID, Touch ID, or Optic ID lock with cold-launch protection and idle timeout (1, 5, 15, or 60 minutes), opt-in from Settings -- iOS: Connection Info tab replaces the per-connection Settings tab, showing host, SSL, SSH tunnel, active database, and live connection status -- MCP Setup sheet adds Zed alongside Claude Desktop, Claude Code, and Cursor with a one-paste `context_servers` snippet - Vim mode in the SQL editor (motions, operators, text objects, registers, macros, marks, search) - Sidebar groups views, materialized views, foreign tables, procedures, and functions; Show DDL opens in a new tab (#1038) - iOS: Live Activity for running queries diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index bbc5150ae..a3d00c31d 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -3518,9 +3518,6 @@ } } } - }, - "A backup is already running." : { - }, "A built-in plugin \"%@\" already provides this bundle ID" : { "localizations" : { @@ -3590,9 +3587,6 @@ } } } - }, - "A restore is already running." : { - }, "A sync conflict was detected and needs to be resolved." : { "localizations" : { @@ -7433,18 +7427,11 @@ "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" : { @@ -9102,14 +9089,6 @@ }, "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." : { @@ -11052,12 +11031,6 @@ } } } - }, - "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" : { @@ -34167,20 +34140,6 @@ } } }, - "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." : { }, @@ -38774,9 +38733,6 @@ "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.", @@ -38785,9 +38741,6 @@ "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" : { @@ -39996,9 +39949,6 @@ } } } - }, - "Save Backup" : { - }, "Save Changes" : { "localizations" : { @@ -41059,9 +41009,6 @@ } } } - }, - "Select a backup file created with pg_dump custom or directory format." : { - }, "Select a Plugin" : { "localizations" : { diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index 0ed0108eb..c6e3c3e29 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -304,12 +304,12 @@ struct AppMenuCommands: Commands { || !(actions.map { PluginManager.shared.supportsImport(for: $0.currentDatabaseType) } ?? true) ) - Button(String(localized: "Backup Dump...")) { + Button(String(localized: "Backup Dump\u{2026}")) { actions?.backupDatabase() } .disabled(!(actions?.isConnected ?? false) || !(actions?.supportsBackup ?? false)) - Button(String(localized: "Restore Dump...")) { + Button(String(localized: "Restore Dump\u{2026}")) { actions?.restoreDatabase() } .disabled( diff --git a/TablePro/Views/Backup/RestoreDatabaseFlow.swift b/TablePro/Views/Backup/RestoreDatabaseFlow.swift index 3073f25e1..c29158d32 100644 --- a/TablePro/Views/Backup/RestoreDatabaseFlow.swift +++ b/TablePro/Views/Backup/RestoreDatabaseFlow.swift @@ -162,7 +162,7 @@ struct RestoreDatabaseFlow: View { 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).") + openPanel.message = String(localized: "Select a dump file produced by pg_dump in custom archive format.") let window = NSApp.keyWindow let response: NSApplication.ModalResponse From c3a2159a62d7610aa4101293bc0146e92b733e42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 13 May 2026 10:07:45 +0700 Subject: [PATCH 03/15] refactor(plugin-postgresql): pass a minimal env to pg_dump and pg_restore --- .../Core/Database/PostgresDumpService.swift | 23 +++++++++++++++---- .../Database/PostgresDumpServiceTests.swift | 18 +++++++++++++++ 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/TablePro/Core/Database/PostgresDumpService.swift b/TablePro/Core/Database/PostgresDumpService.swift index 4ede3ffa5..4c17a4997 100644 --- a/TablePro/Core/Database/PostgresDumpService.swift +++ b/TablePro/Core/Database/PostgresDumpService.swift @@ -238,12 +238,12 @@ final class PostgresDumpService { args.append(fileURL.path) } - var env = ProcessInfo.processInfo.environment + var env = minimalEnvironment() if let password, !password.isEmpty { env["PGPASSWORD"] = password } - if effective.sslConfig.isEnabled { - env["PGSSLMODE"] = pgSSLMode(effective.sslConfig.mode) + if effective.sslConfig.isEnabled, let sslMode = pgSSLMode(effective.sslConfig.mode) { + env["PGSSLMODE"] = sslMode } return PostgresDumpCommand( executable: executable, @@ -253,9 +253,22 @@ final class PostgresDumpService { ) } - static func pgSSLMode(_ mode: SSLMode) -> String { + private static let inheritedEnvironmentKeys: [String] = [ + "PATH", "HOME", "USER", "LOGNAME", "SHELL", "TMPDIR", "LANG", "LC_ALL" + ] + + static func minimalEnvironment() -> [String: String] { + let parent = ProcessInfo.processInfo.environment + var env: [String: String] = [:] + for key in inheritedEnvironmentKeys where parent[key] != nil { + env[key] = parent[key] + } + return env + } + + static func pgSSLMode(_ mode: SSLMode) -> String? { switch mode { - case .disabled: return "disable" + case .disabled: return nil case .preferred: return "prefer" case .required: return "require" case .verifyCa: return "verify-ca" diff --git a/TableProTests/Database/PostgresDumpServiceTests.swift b/TableProTests/Database/PostgresDumpServiceTests.swift index bf7cb71c0..a0085a7ae 100644 --- a/TableProTests/Database/PostgresDumpServiceTests.swift +++ b/TableProTests/Database/PostgresDumpServiceTests.swift @@ -138,6 +138,24 @@ struct PostgresDumpServiceCommandTests { #expect(command.environment["PGSSLMODE"] == expected) } + @Test("environment is restricted to a known allowlist plus libpq vars") + func environmentIsMinimal() { + let command = PostgresDumpService.buildCommand( + kind: .backup, + executable: URL(fileURLWithPath: "/usr/bin/pg_dump"), + effective: connection(sslMode: .required), + database: "sales", + fileURL: URL(fileURLWithPath: "/tmp/x.dump"), + password: "s3cret" + ) + let allowed: Set = [ + "PATH", "HOME", "USER", "LOGNAME", "SHELL", "TMPDIR", "LANG", "LC_ALL", + "PGPASSWORD", "PGSSLMODE" + ] + let unexpected = Set(command.environment.keys).subtracting(allowed) + #expect(unexpected.isEmpty, "unexpected env keys leaked through: \(unexpected)") + } + /// 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 } From 6134c056949d54562e8956549ed2e3e19a702ac0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 13 May 2026 10:08:05 +0700 Subject: [PATCH 04/15] refactor(plugin-postgresql): drop duplicate stderr cap field on runner --- TablePro/Core/Database/PostgresDumpService.swift | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/TablePro/Core/Database/PostgresDumpService.swift b/TablePro/Core/Database/PostgresDumpService.swift index 4c17a4997..38af65e98 100644 --- a/TablePro/Core/Database/PostgresDumpService.swift +++ b/TablePro/Core/Database/PostgresDumpService.swift @@ -352,13 +352,12 @@ final class ProcessPostgresDumpRunner: PostgresDumpRunner { private let stderrPipe = Pipe() private let stateLock = NSLock() private var stderrBuffer = Data() - private var stderrCap = 64_000 private var wasCancelled = false private var terminationResult: PostgresDumpRunResult? private var continuation: CheckedContinuation? func start(_ command: PostgresDumpCommand) throws { - stderrCap = command.stderrByteCap + let stderrCap = command.stderrByteCap process.executableURL = command.executable process.arguments = command.arguments @@ -371,8 +370,8 @@ final class ProcessPostgresDumpRunner: PostgresDumpRunner { guard !chunk.isEmpty, let self else { return } self.stateLock.lock() self.stderrBuffer.append(chunk) - if self.stderrBuffer.count > self.stderrCap { - self.stderrBuffer = Data(self.stderrBuffer.suffix(self.stderrCap)) + if self.stderrBuffer.count > stderrCap { + self.stderrBuffer = Data(self.stderrBuffer.suffix(stderrCap)) } self.stateLock.unlock() } From 478ac717598a3d943b2d8739729bf1022efff2f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 13 May 2026 10:09:52 +0700 Subject: [PATCH 05/15] refactor(coordinator): pick restore source before opening the restore sheet --- .../Views/Backup/RestoreDatabaseFlow.swift | 122 ++++-------------- .../Main/MainContentCommandActions.swift | 36 +++++- .../Views/Main/MainContentCoordinator.swift | 4 +- TablePro/Views/Main/MainContentView.swift | 5 +- 4 files changed, 58 insertions(+), 109 deletions(-) diff --git a/TablePro/Views/Backup/RestoreDatabaseFlow.swift b/TablePro/Views/Backup/RestoreDatabaseFlow.swift index c29158d32..2507a28d5 100644 --- a/TablePro/Views/Backup/RestoreDatabaseFlow.swift +++ b/TablePro/Views/Backup/RestoreDatabaseFlow.swift @@ -1,30 +1,19 @@ -// -// 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 + let sourceURL: URL @State private var service = PostgresDumpService(kind: .restore) - @State private var phase: Phase = .needsSource - @State private var sourceURL: URL? + @State private var phase: Phase = .pickDatabase private enum Phase: Equatable { - case needsSource case pickDatabase case running(database: String) - case finished(database: String, source: URL) + case finished(database: String) case failed(message: String) case cancelled } @@ -32,9 +21,6 @@ struct RestoreDatabaseFlow: View { 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): @@ -46,10 +32,10 @@ struct RestoreDatabaseFlow: View { isCancelling: service.state == .cancelling, onCancel: { service.cancel() } ) - case .finished(let database, let source): + case .finished(let database): BackupResultSheet( kind: .restore, - outcome: .restoreSuccess(database: database, source: source), + outcome: .restoreSuccess(database: database, source: sourceURL), onClose: { isPresented = false }, onShowInFinder: nil ) @@ -69,28 +55,11 @@ struct RestoreDatabaseFlow: View { ) } } - .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 @@ -108,42 +77,34 @@ struct RestoreDatabaseFlow: View { } } - @ViewBuilder private var sourceBanner: some View { - if let url = sourceURL { - HStack(spacing: 8) { - Image(systemName: "doc.zipper") + HStack(spacing: 8) { + Image(systemName: "doc.zipper") + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + Text("Restore from") + .font(.caption) .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) + Text(sourceURL.lastPathComponent) + .font(.body) + .lineLimit(1) + .truncationMode(.middle) } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .frame(width: 420, alignment: .leading) + 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: 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 .finished(let database, _, _): + phase = .finished(database: database) case .failed(let message): phase = .failed(message: message) case .cancelled: @@ -153,51 +114,12 @@ struct RestoreDatabaseFlow: View { } } - @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 dump file produced by pg_dump in custom archive format.") - - 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) + try await service.start(connection: connection, database: database, fileURL: sourceURL) } 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/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index fee5c0219..f2b2d3b07 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -14,6 +14,7 @@ import Observation import os import SwiftUI import TableProPluginKit +import UniformTypeIdentifiers /// Provides command actions for MainContentView, accessible via @FocusedValue @MainActor @@ -692,18 +693,43 @@ final class MainContentCommandActions { 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 + Task { @MainActor [weak self] in + await self?.presentRestoreSourcePicker() + } + } + + private func presentRestoreSourcePicker() async { + let panel = NSOpenPanel() + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + panel.allowedContentTypes = Self.restoreSourceContentTypes + panel.title = String(localized: "Choose Dump File") + panel.prompt = String(localized: "Choose") + panel.message = String(localized: "Select a dump file produced by pg_dump in custom archive format.") + + let response: NSApplication.ModalResponse + if let window = NSApp.keyWindow { + response = await panel.beginSheetModal(for: window) + } else { + response = panel.runModal() + } + guard response == .OK, let url = panel.url else { return } + coordinator?.activeSheet = .restoreDatabase(fileURL: url) + } + + private static var restoreSourceContentTypes: [UTType] { + if let dumpType = UTType(filenameExtension: "dump") { + return [dumpType, .data] + } + return [.data] } func saveAsFavorite() { diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 04ca7aa6a..6826b5332 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -50,7 +50,7 @@ enum ActiveSheet: Identifiable { case importDialog case exportQueryResults case backupDatabase - case restoreDatabase + case restoreDatabase(fileURL: URL) case maintenance(operation: String, tableName: String) var id: String { @@ -63,7 +63,7 @@ enum ActiveSheet: Identifiable { case .importDialog: "importDialog" case .exportQueryResults: "exportQueryResults" case .backupDatabase: "backupDatabase" - case .restoreDatabase: "restoreDatabase" + 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 5492e8285..d40933959 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -205,12 +205,13 @@ struct MainContentView: View { initialDatabase: DatabaseManager.shared.session(for: connection.id)?.currentDatabase ?? connection.database ) - case .restoreDatabase: + case .restoreDatabase(let fileURL): RestoreDatabaseFlow( isPresented: dismissBinding, connection: connectionWithCurrentDatabase, initialDatabase: DatabaseManager.shared.session(for: connection.id)?.currentDatabase - ?? connection.database + ?? connection.database, + sourceURL: fileURL ) case .maintenance(let operation, let tableName): MaintenanceSheet( From 354fb24afcde17445331fbab555f7f4977e96347 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 13 May 2026 10:10:20 +0700 Subject: [PATCH 06/15] fix(coordinator): make backup/restore failure detail scrollable and monospaced --- TablePro/Views/Backup/BackupResultSheet.swift | 43 +++++++++++++++---- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/TablePro/Views/Backup/BackupResultSheet.swift b/TablePro/Views/Backup/BackupResultSheet.swift index 69bcb15c9..6747cccec 100644 --- a/TablePro/Views/Backup/BackupResultSheet.swift +++ b/TablePro/Views/Backup/BackupResultSheet.swift @@ -35,15 +35,7 @@ struct BackupResultSheet: View { .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) - } + detailView HStack(spacing: 12) { if case .backupSuccess = outcome, let onShowInFinder { @@ -64,6 +56,39 @@ struct BackupResultSheet: View { .background(Color(nsColor: .windowBackgroundColor)) } + @ViewBuilder + private var detailView: some View { + switch outcome { + case .failure(let message): + ScrollView { + Text(message) + .font(.system(.callout, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + .padding(8) + } + .frame(maxWidth: .infinity) + .frame(maxHeight: 160) + .background(Color(nsColor: .textBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + default: + if let detail { + Text(detail) + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .lineLimit(6) + .frame(maxWidth: .infinity, alignment: .center) + .textSelection(.enabled) + } + } + } + @ViewBuilder private var icon: some View { switch outcome { From c249c9de53dc0492b677ceeda41a891b8fef0a51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 13 May 2026 10:10:39 +0700 Subject: [PATCH 07/15] refactor(plugin-postgresql): parameterize pg_database_size estimate query --- TablePro/Core/Database/PostgresDumpService.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/TablePro/Core/Database/PostgresDumpService.swift b/TablePro/Core/Database/PostgresDumpService.swift index 38af65e98..bc57729ec 100644 --- a/TablePro/Core/Database/PostgresDumpService.swift +++ b/TablePro/Core/Database/PostgresDumpService.swift @@ -437,10 +437,11 @@ extension PostgresDumpService { 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) + let result = try await driver.executeParameterized( + query: "SELECT pg_database_size($1)", + parameters: [database] + ) guard let text = result.rows.first?.first?.asText else { return nil } return Int64(text) } catch { From 3596d8519fce7bb2d5617dc88286154fbe2bd8d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 13 May 2026 10:11:11 +0700 Subject: [PATCH 08/15] refactor(coordinator): rename DatabaseSwitcherSheet.Mode.switch to .switching --- .../DatabaseSwitcher/DatabaseSwitcherSheet.swift | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift index fd0965fe4..efc95ad30 100644 --- a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift +++ b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift @@ -11,11 +11,8 @@ 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 switching case backup case restore } @@ -59,7 +56,7 @@ struct DatabaseSwitcherSheet: View { init( isPresented: Binding, - mode: Mode = .switch, + mode: Mode = .switching, currentDatabase: String?, currentSchema: String? = nil, databaseType: DatabaseType, @@ -395,7 +392,7 @@ struct DatabaseSwitcherSheet: View { private var navigationTitleString: String { switch mode { - case .switch: + case .switching: return isSchemaMode ? String(localized: "Open Schema") : String(localized: "Open Database") @@ -408,7 +405,7 @@ struct DatabaseSwitcherSheet: View { private var primaryButtonLabel: String { switch mode { - case .switch: return String(localized: "Open") + case .switching: return String(localized: "Open") case .backup: return String(localized: "Backup Dump\u{2026}") case .restore: return String(localized: "Restore Dump\u{2026}") } @@ -416,9 +413,7 @@ struct DatabaseSwitcherSheet: View { 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 } + if mode == .switching, selected == activeName { return true } return false } From 10fb499647c12727d8c9ad9c97915c19a295d211 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 13 May 2026 10:13:03 +0700 Subject: [PATCH 09/15] test(plugin-postgresql): await dump-service state stream instead of sleeping --- .../Core/Database/PostgresDumpService.swift | 35 ++++++++++++---- .../Database/PostgresDumpServiceTests.swift | 41 +++++++++++-------- 2 files changed, 51 insertions(+), 25 deletions(-) diff --git a/TablePro/Core/Database/PostgresDumpService.swift b/TablePro/Core/Database/PostgresDumpService.swift index bc57729ec..c7be98ff4 100644 --- a/TablePro/Core/Database/PostgresDumpService.swift +++ b/TablePro/Core/Database/PostgresDumpService.swift @@ -99,6 +99,27 @@ final class PostgresDumpService { @ObservationIgnored private let runnerFactory: () -> any PostgresDumpRunner @ObservationIgnored private var runner: (any PostgresDumpRunner)? @ObservationIgnored private var byteSizeTask: Task? + @ObservationIgnored private var stateObservers: [UUID: AsyncStream.Continuation] = [:] + + func stateUpdates() -> AsyncStream { + AsyncStream { continuation in + let id = UUID() + stateObservers[id] = continuation + continuation.yield(state) + continuation.onTermination = { @Sendable [weak self] _ in + Task { @MainActor in + self?.stateObservers.removeValue(forKey: id) + } + } + } + } + + private func setState(_ newState: PostgresDumpState) { + state = newState + for continuation in stateObservers.values { + continuation.yield(newState) + } + } /// Default initializer uses the real `Process`-backed runner. init(kind: PostgresDumpKind) { @@ -193,7 +214,7 @@ final class PostgresDumpService { try runner.start(command) self.runner = runner - state = .running(database: database, fileURL: fileURL, bytesProcessed: 0, totalBytes: totalBytesEstimate) + setState(.running(database: database, fileURL: fileURL, bytesProcessed: 0, totalBytes: totalBytesEstimate)) if kind == .backup { startByteSizePolling(url: fileURL, database: database, totalBytes: totalBytesEstimate) } @@ -206,7 +227,7 @@ final class PostgresDumpService { func cancel() { guard case .running = state else { return } - state = .cancelling + setState(.cancelling) runner?.cancel() } @@ -298,13 +319,13 @@ final class PostgresDumpService { if kind == .backup { try? FileManager.default.removeItem(at: fileURL) } - state = .cancelled + setState(.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) + setState(.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 } @@ -315,7 +336,7 @@ final class PostgresDumpService { let summary = result.stderr.isEmpty ? String(format: String(localized: "Process exited with code %d"), Int(result.exitCode)) : result.stderr - state = .failed(message: summary) + setState(.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)") } @@ -328,12 +349,12 @@ final class PostgresDumpService { guard case .running = self.state else { return } let size = (try? FileManager.default .attributesOfItem(atPath: url.path)[.size] as? Int64) ?? 0 - self.state = .running( + self.setState(.running( database: database, fileURL: url, bytesProcessed: size, totalBytes: totalBytes - ) + )) } } } diff --git a/TableProTests/Database/PostgresDumpServiceTests.swift b/TableProTests/Database/PostgresDumpServiceTests.swift index a0085a7ae..db79854c1 100644 --- a/TableProTests/Database/PostgresDumpServiceTests.swift +++ b/TableProTests/Database/PostgresDumpServiceTests.swift @@ -218,6 +218,7 @@ struct PostgresDumpServiceStateMachineTests { func successfulBackup() async throws { let runner = FakeDumpRunner() let service = PostgresDumpService(kind: .backup, runnerFactory: { runner }) + let updates = service.stateUpdates() #expect(service.state == .idle) try service.run( @@ -227,7 +228,6 @@ struct PostgresDumpServiceStateMachineTests { totalBytesEstimate: 1_000 ) - // Now running if case .running(let db, _, _, let total) = service.state { #expect(db == "sales") #expect(total == 1_000) @@ -236,12 +236,12 @@ struct PostgresDumpServiceStateMachineTests { } runner.finish(.init(exitCode: 0, stderr: "", wasCancelled: false)) - try await waitFor { if case .finished = service.state { return true }; return false } + let finalState = try await firstMatching(updates) { if case .finished = $0 { return true }; return false } - if case .finished(let db, _, _) = service.state { + if case .finished(let db, _, _) = finalState { #expect(db == "sales") } else { - Issue.record("expected finished, got \(service.state)") + Issue.record("expected finished, got \(finalState)") } } @@ -249,6 +249,7 @@ struct PostgresDumpServiceStateMachineTests { func failedRun() async throws { let runner = FakeDumpRunner() let service = PostgresDumpService(kind: .restore, runnerFactory: { runner }) + let updates = service.stateUpdates() try service.run( command: fakeCommand(), @@ -257,12 +258,12 @@ struct PostgresDumpServiceStateMachineTests { ) runner.finish(.init(exitCode: 1, stderr: "FATAL: connection refused", wasCancelled: false)) - try await waitFor { if case .failed = service.state { return true }; return false } + let finalState = try await firstMatching(updates) { if case .failed = $0 { return true }; return false } - if case .failed(let message) = service.state { + if case .failed(let message) = finalState { #expect(message == "FATAL: connection refused") } else { - Issue.record("expected failed, got \(service.state)") + Issue.record("expected failed, got \(finalState)") } } @@ -270,6 +271,7 @@ struct PostgresDumpServiceStateMachineTests { func cancelRun() async throws { let runner = FakeDumpRunner() let service = PostgresDumpService(kind: .backup, runnerFactory: { runner }) + let updates = service.stateUpdates() try service.run( command: fakeCommand(), @@ -282,8 +284,8 @@ struct PostgresDumpServiceStateMachineTests { #expect(runner.cancelCount == 1) runner.finish(.init(exitCode: -15, stderr: "", wasCancelled: true)) - try await waitFor { service.state == .cancelled } - #expect(service.state == .cancelled) + let finalState = try await firstMatching(updates) { $0 == .cancelled } + #expect(finalState == .cancelled) } @Test("calling run while already running throws alreadyRunning") @@ -310,6 +312,7 @@ struct PostgresDumpServiceStateMachineTests { func emptyStderrFallback() async throws { let runner = FakeDumpRunner() let service = PostgresDumpService(kind: .backup, runnerFactory: { runner }) + let updates = service.stateUpdates() try service.run( command: fakeCommand(), @@ -317,21 +320,23 @@ struct PostgresDumpServiceStateMachineTests { 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 } + let finalState = try await firstMatching(updates) { if case .failed = $0 { return true }; return false } - if case .failed(let message) = service.state { + if case .failed(let message) = finalState { #expect(message.contains("42")) } else { - Issue.record("expected failed, got \(service.state)") + Issue.record("expected failed, got \(finalState)") } } - /// 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) + private func firstMatching( + _ stream: AsyncStream, + where predicate: @Sendable (PostgresDumpState) -> Bool + ) async throws -> PostgresDumpState { + for await state in stream where predicate(state) { + return state } - Issue.record("timed out waiting for condition") + Issue.record("state stream ended before predicate matched") + return .idle } } From d77ca9bdc5a0ab9fc8bcc41a2873082c3b4d1bac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 13 May 2026 10:18:26 +0700 Subject: [PATCH 10/15] fix(plugin-postgresql): mark dump command builders nonisolated --- TablePro/Core/Database/PostgresDumpService.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/TablePro/Core/Database/PostgresDumpService.swift b/TablePro/Core/Database/PostgresDumpService.swift index c7be98ff4..fd7534cfa 100644 --- a/TablePro/Core/Database/PostgresDumpService.swift +++ b/TablePro/Core/Database/PostgresDumpService.swift @@ -233,7 +233,7 @@ final class PostgresDumpService { // MARK: - Command Construction - static func buildCommand( + nonisolated static func buildCommand( kind: PostgresDumpKind, executable: URL, effective: DatabaseConnection, @@ -274,11 +274,11 @@ final class PostgresDumpService { ) } - private static let inheritedEnvironmentKeys: [String] = [ + nonisolated private static let inheritedEnvironmentKeys: [String] = [ "PATH", "HOME", "USER", "LOGNAME", "SHELL", "TMPDIR", "LANG", "LC_ALL" ] - static func minimalEnvironment() -> [String: String] { + nonisolated static func minimalEnvironment() -> [String: String] { let parent = ProcessInfo.processInfo.environment var env: [String: String] = [:] for key in inheritedEnvironmentKeys where parent[key] != nil { @@ -287,7 +287,7 @@ final class PostgresDumpService { return env } - static func pgSSLMode(_ mode: SSLMode) -> String? { + nonisolated static func pgSSLMode(_ mode: SSLMode) -> String? { switch mode { case .disabled: return nil case .preferred: return "prefer" From 3c48e915b52ea632b333aa9ea722fb523e6ede3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 13 May 2026 10:34:12 +0700 Subject: [PATCH 11/15] fix(plugin-postgresql): make dump state-stream eager and fake runner re-entrant --- .../Core/Database/PostgresDumpService.swift | 16 ++++++------ .../Database/PostgresDumpServiceTests.swift | 25 +++++++++++++------ .../DataGridRowViewSetValueTests.swift | 1 + 3 files changed, 26 insertions(+), 16 deletions(-) diff --git a/TablePro/Core/Database/PostgresDumpService.swift b/TablePro/Core/Database/PostgresDumpService.swift index fd7534cfa..7dca9b75c 100644 --- a/TablePro/Core/Database/PostgresDumpService.swift +++ b/TablePro/Core/Database/PostgresDumpService.swift @@ -102,16 +102,16 @@ final class PostgresDumpService { @ObservationIgnored private var stateObservers: [UUID: AsyncStream.Continuation] = [:] func stateUpdates() -> AsyncStream { - AsyncStream { continuation in - let id = UUID() - stateObservers[id] = continuation - continuation.yield(state) - continuation.onTermination = { @Sendable [weak self] _ in - Task { @MainActor in - self?.stateObservers.removeValue(forKey: id) - } + let (stream, continuation) = AsyncStream.makeStream() + let id = UUID() + stateObservers[id] = continuation + continuation.yield(state) + continuation.onTermination = { @Sendable [weak self] _ in + Task { @MainActor in + self?.stateObservers.removeValue(forKey: id) } } + return stream } private func setState(_ newState: PostgresDumpState) { diff --git a/TableProTests/Database/PostgresDumpServiceTests.swift b/TableProTests/Database/PostgresDumpServiceTests.swift index db79854c1..695edc7b1 100644 --- a/TableProTests/Database/PostgresDumpServiceTests.swift +++ b/TableProTests/Database/PostgresDumpServiceTests.swift @@ -165,11 +165,11 @@ struct PostgresDumpServiceCommandTests { // 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 var bufferedResult: PostgresDumpRunResult? private let lock = NSLock() func start(_ command: PostgresDumpCommand) throws { @@ -185,20 +185,29 @@ private final class FakeDumpRunner: PostgresDumpRunner, @unchecked Sendable { var result: PostgresDumpRunResult { get async { await withCheckedContinuation { continuation in - self.lock.lock() + lock.lock() + if let buffered = bufferedResult { + bufferedResult = nil + lock.unlock() + continuation.resume(returning: buffered) + return + } self.continuation = continuation - self.lock.unlock() + 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) + if let continuation = self.continuation { + self.continuation = nil + lock.unlock() + continuation.resume(returning: outcome) + } else { + bufferedResult = outcome + lock.unlock() + } } } diff --git a/TableProTests/Views/Results/DataGridRowViewSetValueTests.swift b/TableProTests/Views/Results/DataGridRowViewSetValueTests.swift index a5f4c66ff..5f828a07f 100644 --- a/TableProTests/Views/Results/DataGridRowViewSetValueTests.swift +++ b/TableProTests/Views/Results/DataGridRowViewSetValueTests.swift @@ -9,6 +9,7 @@ import TableProPluginKit import Testing @Suite("DataGridRowView Set Value presets") +@MainActor struct DataGridRowViewSetValueTests { @Test("date column offers CURRENT_DATE only") func dateColumnOffersCurrentDate() { From 99e8660098d28a2df51ea27351231b7cc897e482 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 13 May 2026 10:35:10 +0700 Subject: [PATCH 12/15] style(datagrid): sort imports in DataGridRowViewSetValueTests --- TableProTests/Views/Results/DataGridRowViewSetValueTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TableProTests/Views/Results/DataGridRowViewSetValueTests.swift b/TableProTests/Views/Results/DataGridRowViewSetValueTests.swift index 5f828a07f..3bc7cad8d 100644 --- a/TableProTests/Views/Results/DataGridRowViewSetValueTests.swift +++ b/TableProTests/Views/Results/DataGridRowViewSetValueTests.swift @@ -4,8 +4,8 @@ // import Foundation -import TableProPluginKit @testable import TablePro +import TableProPluginKit import Testing @Suite("DataGridRowView Set Value presets") From 034c33f6242ccb7e2bb85d0bf4ee3ebabf78e4b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 13 May 2026 11:23:58 +0700 Subject: [PATCH 13/15] chore(plugin-postgresql): regenerate xcstrings for new dump error messages --- TablePro/Resources/Localizable.xcstrings | 37 ++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index a3d00c31d..ea96161b5 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -1313,6 +1313,16 @@ } } }, + "%@ 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 > %@." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ 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 > %2$@." + } + } + } + }, "%@, %@" : { "localizations" : { "en" : { @@ -5911,6 +5921,9 @@ } } } + }, + "An operation is already running." : { + }, "An unknown sync error occurred: %@" : { "localizations" : { @@ -9089,6 +9102,12 @@ }, "Choose AI provider and model" : { + }, + "Choose Dump File" : { + + }, + "Choose where to save the dump of “%@”." : { + }, "Choose your client and follow the steps to connect it to TablePro." : { @@ -11031,6 +11050,9 @@ } } } + }, + "Connect to the database before starting this operation." : { + }, "Connect to the internet to verify your license." : { "localizations" : { @@ -16771,6 +16793,9 @@ } } } + }, + "Dump operations are only supported for PostgreSQL and Redshift connections." : { + }, "duplicate" : { "localizations" : { @@ -25944,6 +25969,9 @@ } } } + }, + "Keep Backing Up" : { + }, "Keep entries for:" : { "localizations" : { @@ -26014,6 +26042,9 @@ } } } + }, + "Keep Restoring" : { + }, "Keep Running" : { "localizations" : { @@ -40021,6 +40052,9 @@ } } } + }, + "Save Dump" : { + }, "Save Failed" : { "localizations" : { @@ -41009,6 +41043,9 @@ } } } + }, + "Select a dump file produced by pg_dump in custom archive format." : { + }, "Select a Plugin" : { "localizations" : { From 216f585c26194749d7562b4dc54d6b687197f443 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 13 May 2026 11:24:06 +0700 Subject: [PATCH 14/15] docs: correct database count, MSSQL distribution, BigQuery card, em dash --- docs/changelog.mdx | 2 +- docs/databases/overview.mdx | 3 +++ docs/index.mdx | 8 ++++---- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/changelog.mdx b/docs/changelog.mdx index b73051b31..5fe42863e 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -26,7 +26,7 @@ rss: true ### Improvements - **Row Numbers Continue Across Pages**: The `#` column reads `1001-2000` on page 2 instead of restarting at 1, and auto-sizes so long numbers no longer clip - - **Foreign Key Preview Follows Selection**: Press Space on an FK cell, then arrow up or down — the popover slides to the next row and refreshes its content + - **Foreign Key Preview Follows Selection**: Press Space on an FK cell, then arrow up or down. The popover slides to the next row and refreshes its content - **Inline Editor for Date Cells**: Double-click a `DATE`, `DATETIME`, or `TIMESTAMP` cell to type a new value directly; the popover date picker is removed - **Inline Connecting State**: The connection window shows the connecting state with a Cancel button instead of an empty sidebar diff --git a/docs/databases/overview.mdx b/docs/databases/overview.mdx index ddc8c2639..8ac4403e4 100644 --- a/docs/databases/overview.mdx +++ b/docs/databases/overview.mdx @@ -49,6 +49,9 @@ Natively supported: Amazon DynamoDB via AWS SDK. NoSQL key-value and document database + + Google BigQuery analytics warehouse via REST API. Service account or OAuth + Etcd distributed key-value store via gRPC API. Default port: 2379 diff --git a/docs/index.mdx b/docs/index.mdx index 4d851a42d..37829e4c9 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -1,6 +1,6 @@ --- title: Introduction -description: Native macOS client for every database - MySQL, PostgreSQL, SQLite, MongoDB, Redis, and 15+ more +description: Native macOS database client for MySQL, PostgreSQL, SQLite, MongoDB, Redis, and a dozen more. --- # TablePro @@ -28,7 +28,7 @@ Native macOS client for every database. Built on SwiftUI and AppKit. Ships under Swift & Apple frameworks. No Electron. Pure macOS responsiveness. - 18 databases: MySQL, PostgreSQL, SQLite, MongoDB, Redis, Oracle, and more. + 17 databases: MySQL, PostgreSQL, SQLite, MongoDB, Redis, Oracle, and more. Context-aware autocomplete with schema and syntax awareness. @@ -50,7 +50,7 @@ Native macOS client for every database. Built on SwiftUI and AppKit. Ships under **Terminal**: Built-in database CLI (mysql, psql, redis-cli, mongosh, etc.) with SSH and Docker support. **MCP Server**: Expose your connections to AI tools via the Model Context Protocol. **External API**: Drive TablePro from Raycast, Cursor, Claude Desktop, and other MCP clients. URL scheme, MCP, and one-click pairing. -**Plugin System**: 8 built-in drivers, 10 more via the plugin registry. Third-party plugins supported. +**Plugin System**: 5 bundled drivers (covering 7 databases) plus 10 more from the plugin registry. Third-party plugins supported. **iCloud Sync**: Sync connections, groups, tags, settings, and SSH profiles across Macs. **Safe Mode**: 6 per-connection protection levels from silent alerts to Touch ID and read-only. **Themes**: Light, dark, and custom editor themes. Per-connection color labels. @@ -64,7 +64,7 @@ Native macOS client for every database. Built on SwiftUI and AppKit. Ships under | PostgreSQL | 5432 | Built-in | | SQLite | N/A (file-based) | Built-in | | Amazon Redshift | 5439 | Built-in | -| Microsoft SQL Server | 1433 | Built-in | +| Microsoft SQL Server | 1433 | Plugin | | ClickHouse | 8123 | Built-in | | Redis | 6379 | Built-in | | MongoDB | 27017 | Plugin | From beea2a9906a86d4fbbdc7a8254cde489c23a5bf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 13 May 2026 11:24:06 +0700 Subject: [PATCH 15/15] docs(plugin-postgresql): add Backup and Restore page --- docs/docs.json | 1 + docs/features/backup-restore.mdx | 76 ++++++++++++++++++++++++++++++++ docs/features/overview.mdx | 3 ++ 3 files changed, 80 insertions(+) create mode 100644 docs/features/backup-restore.mdx diff --git a/docs/docs.json b/docs/docs.json index 07261caf5..6182e585d 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -111,6 +111,7 @@ "group": "Data Management", "pages": [ "features/import-export", + "features/backup-restore", "features/change-tracking", "features/filtering" ] diff --git a/docs/features/backup-restore.mdx b/docs/features/backup-restore.mdx new file mode 100644 index 000000000..1a1955b6c --- /dev/null +++ b/docs/features/backup-restore.mdx @@ -0,0 +1,76 @@ +--- +title: Backup & Restore +description: Dump and restore PostgreSQL or Redshift databases with pg_dump and pg_restore, with progress, cancel, and SSH tunnel reuse. +--- + +# Backup & Restore + +Dump a PostgreSQL or Redshift database to a `.dump` file with `pg_dump`, and restore one back with `pg_restore`. Both live in the **File** menu on any connected PostgreSQL or Redshift session. + + +PostgreSQL and Redshift only. Restore is disabled on read-only Safe Mode. Backup stays available on read-only connections because it does not write to the database. + + +## Requirements + +TablePro shells out to your local `pg_dump` and `pg_restore`. Install them with Homebrew: + +```bash +brew install libpq +brew link --force libpq +``` + +If a custom path is set under **Settings > Terminal > CLI Paths > pg_dump** or **pg_restore**, TablePro uses it. Otherwise it looks on `PATH` and the standard Homebrew locations. + +## Backup Dump + +1. Choose **File > Backup Dump…**. +2. Pick the database to dump. +3. Choose where to save the `.dump` file. +4. Watch progress. Click **Cancel** to stop and remove the partial file. + +The default filename is `-.dump`. Dumps use the custom archive format (`pg_dump -Fc`) so they round-trip through `pg_restore`. + +{/* Screenshot: Backup progress sheet showing live byte counter and Cancel button */} + + Backup progress sheet + Backup progress sheet + + +When the dump finishes, the result sheet shows the file size and a **Show in Finder** button. + +## Restore Dump + +1. Choose **File > Restore Dump…**. +2. Pick the `.dump` file. +3. Pick the target database. +4. Watch progress. Click **Cancel** to stop. The target database is not rolled back; review and clean up as needed. + +`pg_restore` runs with `--no-owner --no-acl`, so the connection user owns the restored objects. TablePro does not pass `--clean`; restoring on top of an existing schema with conflicting objects produces stderr errors that show in the result sheet. + +## SSH Tunnels + +Both flows reuse the connection's active SSH tunnel. No second port forward is opened. + +## Cancelling + +Cancel asks for confirmation, then sends `SIGTERM`. + +- **Backup**: TablePro removes the partial `.dump` file. +- **Restore**: the target database may be left in a partial state. Drop it and restore again into a fresh database, or clean up the partial objects manually. + +## Failures + +A non-zero exit shows the last 64 KB of `pg_dump` or `pg_restore` stderr in a scrollable monospaced view. Common causes: + +- **Binary not found**: install `libpq`, or set a custom path under **Settings > Terminal > CLI Paths**. +- **Authentication failed**: TablePro passes the connection password via `PGPASSWORD` and runs the tools with `--no-password` to avoid a TTY prompt. If the role lacks `LOGIN` or the database is wrong, the failure surfaces here. +- **Restore conflict**: the target database has objects that conflict with the dump. Restore into a fresh database or drop the conflicting objects first. diff --git a/docs/features/overview.mdx b/docs/features/overview.mdx index 614aefa0f..cdc1b423a 100644 --- a/docs/features/overview.mdx +++ b/docs/features/overview.mdx @@ -72,6 +72,9 @@ TablePro opens with a sidebar-style welcome window, in the style of the Xcode la CSV, JSON, SQL dumps in and out. + + pg_dump and pg_restore for PostgreSQL and Redshift. + Stage edits, preview SQL, save or discard.