Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- The connection Export Options dialog keeps a steady size when you turn on Include Credentials, and saves through the standard macOS save dialog.
- Data grid now serves the row count from its existing cache instead of recomputing it on every layout pass, reducing CPU churn while scrolling large result sets.
- Typing in the sidebar table search stays responsive on databases with thousands of tables; filtering runs after a short pause instead of on every keystroke.

Expand Down
16 changes: 11 additions & 5 deletions TablePro/Core/Services/Export/ConnectionExportService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -251,9 +251,12 @@ enum ConnectionExportService {
}
}

static func exportData(_ connections: [DatabaseConnection]) throws -> Data {
try encode(buildEnvelope(for: connections))
}

static func exportConnections(_ connections: [DatabaseConnection], to url: URL) throws {
let envelope = buildEnvelope(for: connections)
let data = try encode(envelope)
let data = try exportData(connections)

do {
try data.write(to: url, options: .atomic)
Expand Down Expand Up @@ -325,14 +328,17 @@ enum ConnectionExportService {
)
}

static func exportEncryptedData(_ connections: [DatabaseConnection], passphrase: String) throws -> Data {
let jsonData = try encode(buildEnvelopeWithCredentials(for: connections))
return try ConnectionExportCrypto.encrypt(data: jsonData, passphrase: passphrase)
}

static func exportConnectionsEncrypted(
_ connections: [DatabaseConnection],
to url: URL,
passphrase: String
) throws {
let envelope = buildEnvelopeWithCredentials(for: connections)
let jsonData = try encode(envelope)
let encryptedData = try ConnectionExportCrypto.encrypt(data: jsonData, passphrase: passphrase)
let encryptedData = try exportEncryptedData(connections, passphrase: passphrase)

do {
try encryptedData.write(to: url, options: .atomic)
Expand Down
29 changes: 29 additions & 0 deletions TablePro/Views/Connection/ConnectionExportDocument.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
//
// ConnectionExportDocument.swift
// TablePro
//

import SwiftUI
import UniformTypeIdentifiers

struct ConnectionExportDocument: FileDocument {
static let readableContentTypes: [UTType] = [.tableproConnectionShare]
static let writableContentTypes: [UTType] = [.tableproConnectionShare]

let data: Data

init(data: Data) {
self.data = data
}

init(configuration: ReadConfiguration) throws {
guard let contents = configuration.file.regularFileContents else {
throw CocoaError(.fileReadCorruptFile)
}
data = contents
}

func fileWrapper(configuration _: WriteConfiguration) throws -> FileWrapper {
FileWrapper(regularFileWithContents: data)
}
}
231 changes: 140 additions & 91 deletions TablePro/Views/Connection/ConnectionExportOptionsSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@
// ConnectionExportOptionsSheet.swift
// TablePro
//
// Sheet for choosing export options before saving a .tablepro file.
//

import SwiftUI
import UniformTypeIdentifiers
Expand All @@ -15,120 +13,171 @@ struct ConnectionExportOptionsSheet: View {
@State private var includeCredentials = false
@State private var passphrase = ""
@State private var confirmPassphrase = ""
@State private var exportDocument: ConnectionExportDocument?
@State private var isExporting = false
@State private var exportError: String?

private var isProAvailable: Bool {
LicenseManager.shared.isFeatureAvailable(.encryptedExport)
}

private var passphraseState: ConnectionExportPassphraseState {
ConnectionExportPassphraseState.evaluate(passphrase: passphrase, confirmation: confirmPassphrase)
}

private var canExport: Bool {
if includeCredentials {
return (passphrase as NSString).length >= 8 && passphrase == confirmPassphrase
}
return true
guard includeCredentials else { return true }
return passphraseState.allowsExport
}

private var defaultFilename: String {
connections.count == 1 ? connections[0].name : String(localized: "Connections")
}

var body: some View {
VStack(spacing: 0) {
Text(String(localized: "Export Options"))
.font(.body.weight(.semibold))
.padding(.vertical, 12)
header

Divider()

Form {
Section {
HStack(spacing: 6) {
Toggle("Include Credentials", isOn: $includeCredentials)
.toggleStyle(.checkbox)
.disabled(!isProAvailable)
if !isProAvailable {
ProBadge()
}
}
} footer: {
if includeCredentials {
Text("Passwords will be encrypted with the passphrase you provide.")
}
}
options
.padding(20)

if includeCredentials {
Section {
LabeledContent(String(localized: "Passphrase")) {
SecureField(String(localized: "8+ characters"), text: $passphrase)
}
LabeledContent(String(localized: "Confirm")) {
SecureField(String(localized: "Re-enter passphrase"), text: $confirmPassphrase)
}
}
Spacer(minLength: 0)

Divider()

footer
.padding(16)
}
.frame(width: 440, height: 300)
.fileExporter(
isPresented: $isExporting,
document: exportDocument,
contentType: .tableproConnectionShare,
defaultFilename: defaultFilename
) { result in
if case .failure(let error) = result, (error as NSError).code != NSUserCancelledError {
exportError = error.localizedDescription
return
}
dismiss()
}
.alert(
String(localized: "Export Failed"),
isPresented: Binding(get: { exportError != nil }, set: { if !$0 { exportError = nil } })
) {
Button(String(localized: "OK"), role: .cancel) { exportError = nil }
} message: {
if let exportError {
Text(exportError)
}
}
}

private var header: some View {
VStack(spacing: 2) {
Text("Export Options")
.font(.headline)
Text(exportSummary)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.vertical, 14)
}

private var exportSummary: String {
connections.count == 1
? connections[0].name
: String(format: String(localized: "%d connections"), connections.count)
}

if !passphrase.isEmpty && !confirmPassphrase.isEmpty && passphrase != confirmPassphrase {
Section {
Label(String(localized: "Passphrases do not match"), systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
}
private var options: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Toggle("Include Credentials", isOn: $includeCredentials)
.toggleStyle(.checkbox)
.disabled(!isProAvailable)
if !isProAvailable {
ProBadge()
}
}
Text("Off by default. Turn it on to encrypt saved passwords with a passphrase.")
.font(.caption)
.foregroundStyle(.secondary)
}
.formStyle(.grouped)
.scrollContentBackground(.hidden)

Divider()

HStack {
Button("Cancel") { dismiss() }
.keyboardShortcut(.cancelAction)
Spacer()
Button("Export...") { performExport() }
.buttonStyle(.borderedProminent)
.keyboardShortcut(.defaultAction)
.disabled(!canExport)
if includeCredentials {
passphraseFields
}
.padding(12)
}
.frame(width: 420)
.frame(maxWidth: .infinity, alignment: .leading)
}

private func performExport() {
let shouldEncrypt = includeCredentials && isProAvailable
let capturedPassphrase = passphrase
let capturedConnections = connections

// Zero passphrase state before dismissing
passphrase = ""
confirmPassphrase = ""
dismiss()

Task { @MainActor in
try? await Task.sleep(for: .milliseconds(200))
let panel = NSSavePanel()
panel.allowedContentTypes = [.tableproConnectionShare]
let defaultName = capturedConnections.count == 1
? "\(capturedConnections[0].name).tablepro"
: "Connections.tablepro"
panel.nameFieldStringValue = defaultName
panel.canCreateDirectories = true
guard let window = NSApp.keyWindow else { return }
panel.beginSheetModal(for: window) { response in
guard response == .OK, let url = panel.url else { return }

do {
if shouldEncrypt {
try ConnectionExportService.exportConnectionsEncrypted(
capturedConnections,
to: url,
passphrase: capturedPassphrase
)
} else {
try ConnectionExportService.exportConnections(capturedConnections, to: url)
}
} catch {
AlertHelper.showErrorSheet(
title: String(localized: "Export Failed"),
message: error.localizedDescription,
window: window
)
private var passphraseFields: some View {
VStack(alignment: .leading, spacing: 8) {
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
GridRow {
Text("Passphrase")
.gridColumnAlignment(.trailing)
SecureField(String(localized: "8+ characters"), text: $passphrase)
.textFieldStyle(.roundedBorder)
}
GridRow {
Text("Confirm")
.gridColumnAlignment(.trailing)
SecureField(String(localized: "Re-enter passphrase"), text: $confirmPassphrase)
.textFieldStyle(.roundedBorder)
}
}

validationMessage
.frame(height: 16, alignment: .leading)
}
}

@ViewBuilder
private var validationMessage: some View {
switch passphraseState {
case .tooShort:
warningLabel(String(localized: "Use at least 8 characters"))
case .mismatch:
warningLabel(String(localized: "Passphrases do not match"))
case .empty, .incomplete, .ok:
EmptyView()
}
}

private func warningLabel(_ text: String) -> some View {
Label(text, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.orange)
}

private var footer: some View {
HStack {
Button("Cancel") { dismiss() }
.keyboardShortcut(.cancelAction)
Spacer()
Button("Export...") { performExport() }
.buttonStyle(.borderedProminent)
.keyboardShortcut(.defaultAction)
.disabled(!canExport)
}
}

private func performExport() {
do {
let data = includeCredentials && isProAvailable
? try ConnectionExportService.exportEncryptedData(connections, passphrase: passphrase)
: try ConnectionExportService.exportData(connections)
passphrase = ""
confirmPassphrase = ""
exportDocument = ConnectionExportDocument(data: data)
isExporting = true
} catch {
exportError = error.localizedDescription
}
}
}
28 changes: 28 additions & 0 deletions TablePro/Views/Connection/ConnectionExportPassphraseState.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
//
// ConnectionExportPassphraseState.swift
// TablePro
//

import Foundation

enum ConnectionExportPassphraseState: Equatable {
case empty
case tooShort
case incomplete
case mismatch
case ok

static let minimumLength = 8

static func evaluate(passphrase: String, confirmation: String) -> ConnectionExportPassphraseState {
if passphrase.isEmpty { return .empty }
if (passphrase as NSString).length < minimumLength { return .tooShort }
if confirmation.isEmpty { return .incomplete }
if passphrase != confirmation { return .mismatch }
return .ok
}

var allowsExport: Bool {
self == .ok
}
}
Loading
Loading