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 @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- iOS: page-based pagination for data browser
- iOS: filter bar with 16 operators, AND/OR logic
- iOS: persistent query history with timestamps
- iOS: export to clipboard (JSON, CSV, SQL INSERT)

## [0.27.4] - 2026-04-05

Expand Down
110 changes: 110 additions & 0 deletions TableProMobile/TableProMobile/Helpers/ClipboardExporter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
//
// ClipboardExporter.swift
// TableProMobile
//

import Foundation
import TableProModels
import UIKit

enum ExportFormat: String, CaseIterable, Identifiable {
case json = "JSON"
case csv = "CSV"
case sqlInsert = "SQL INSERT"
var id: String { rawValue }
}

enum ClipboardExporter {
static func exportRow(columns: [ColumnInfo], row: [String?], format: ExportFormat, tableName: String? = nil) -> String {
switch format {
case .json:
return rowToJson(columns: columns, row: row)
case .csv:
return rowToCsv(columns: columns, row: row, includeHeader: true)
case .sqlInsert:
return rowToInsert(columns: columns, row: row, tableName: tableName ?? "table")
}
}

static func exportRows(columns: [ColumnInfo], rows: [[String?]], format: ExportFormat, tableName: String? = nil) -> String {
switch format {
case .json:
let objects = rows.map { rowToJson(columns: columns, row: $0) }
return "[\n" + objects.joined(separator: ",\n") + "\n]"
case .csv:
let header = columns.map { escapeCsvField($0.name) }.joined(separator: ",")
let dataLines = rows.map { row in
columns.indices.map { i in
escapeCsvField(i < row.count ? row[i] ?? "NULL" : "NULL")
}.joined(separator: ",")
}
return ([header] + dataLines).joined(separator: "\n")
case .sqlInsert:
let name = tableName ?? "table"
return rows.map { rowToInsert(columns: columns, row: $0, tableName: name) }.joined(separator: "\n")
}
}

static func copyToClipboard(_ text: String) {
UIPasteboard.general.string = text
}

// MARK: - Private

private static func rowToJson(columns: [ColumnInfo], row: [String?]) -> String {
var pairs: [String] = []
for (i, col) in columns.enumerated() {
let value = i < row.count ? row[i] : nil
let key = " \"\(escapeJsonString(col.name))\""
if let value {
if Int64(value) != nil || Double(value) != nil {
pairs.append("\(key): \(value)")
} else if value == "true" || value == "false" {
pairs.append("\(key): \(value)")
} else {
pairs.append("\(key): \"\(escapeJsonString(value))\"")
}
} else {
pairs.append("\(key): null")
}
}
return "{\n" + pairs.joined(separator: ",\n") + "\n}"
}

private static func rowToCsv(columns: [ColumnInfo], row: [String?], includeHeader: Bool) -> String {
var lines: [String] = []
if includeHeader {
lines.append(columns.map { escapeCsvField($0.name) }.joined(separator: ","))
}
let dataLine = columns.indices.map { i in
escapeCsvField(i < row.count ? row[i] ?? "NULL" : "NULL")
}.joined(separator: ",")
lines.append(dataLine)
return lines.joined(separator: "\n")
}

private static func rowToInsert(columns: [ColumnInfo], row: [String?], tableName: String) -> String {
let cols = columns.map { "\"\($0.name)\"" }.joined(separator: ", ")
let vals = columns.indices.map { i in
let value = i < row.count ? row[i] : nil
guard let value else { return "NULL" }
return "'\(value.replacingOccurrences(of: "'", with: "''"))'"
}.joined(separator: ", ")
return "INSERT INTO \"\(tableName)\" (\(cols)) VALUES (\(vals));"
}

private static func escapeCsvField(_ field: String) -> String {
if field.contains(",") || field.contains("\"") || field.contains("\n") || field.contains("\r") {
return "\"" + field.replacingOccurrences(of: "\"", with: "\"\"") + "\""
}
return field
}

private static func escapeJsonString(_ str: String) -> String {
str.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
.replacingOccurrences(of: "\n", with: "\\n")
.replacingOccurrences(of: "\r", with: "\\r")
.replacingOccurrences(of: "\t", with: "\\t")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ struct ConnectionColorPicker: View {
.contentShape(Circle())
}
.buttonStyle(.plain)
.accessibilityLabel(Text(color.rawValue))
}
}
.padding(.vertical, 4)
Expand Down
57 changes: 24 additions & 33 deletions TableProMobile/TableProMobile/Views/ConnectedView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ struct ConnectedView: View {
@State private var tables: [TableInfo] = []
@State private var isConnecting = true
@State private var appError: AppError?
@State private var toastMessage: String?
@State private var toastTask: Task<Void, Never>?
@State private var failureAlertMessage: String?
@State private var showFailureAlert = false
@State private var selectedTab = ConnectedTab.tables
@State private var queryHistory: [QueryHistoryItem] = []
private let historyStorage = QueryHistoryStorage()
Expand Down Expand Up @@ -77,28 +77,24 @@ struct ConnectedView: View {
.animation(.default, value: isSwitching)
}
}
.overlay(alignment: .bottom) {
if let toastMessage {
ErrorToast(message: toastMessage)
.onAppear {
toastTask?.cancel()
toastTask = Task {
try? await Task.sleep(nanoseconds: 3_000_000_000)
withAnimation { self.toastMessage = nil }
}
}
.onDisappear {
toastTask?.cancel()
toastTask = nil
}
}
.alert("Error", isPresented: $showFailureAlert) {
Button("OK", role: .cancel) {}
} message: {
Text(failureAlertMessage ?? "")
}
.animation(.default, value: toastMessage)
.navigationTitle(displayName)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
Picker("Tab", selection: $selectedTab) {
Text("Tables").tag(ConnectedTab.tables)
Text("Query").tag(ConnectedTab.query)
}
.pickerStyle(.segmented)
.frame(width: 200)
}
if supportsDatabaseSwitching && databases.count > 1 {
ToolbarItem(placement: .principal) {
ToolbarItem(placement: .topBarLeading) {
Menu {
ForEach(databases, id: \.self) { db in
Button {
Expand All @@ -114,7 +110,7 @@ struct ConnectedView: View {
} label: {
HStack(spacing: 4) {
Text(activeDatabase)
.font(.headline)
.font(.subheadline)
if isSwitching {
ProgressView()
.controlSize(.mini)
Expand Down Expand Up @@ -163,15 +159,6 @@ struct ConnectedView: View {

private var connectedContent: some View {
VStack(spacing: 0) {
Picker("Tab", selection: $selectedTab) {
ForEach(ConnectedTab.allCases, id: \.self) { tab in
Text(tab.rawValue).tag(tab)
}
}
.pickerStyle(.segmented)
.padding(.horizontal)
.padding(.vertical, 8)

switch selectedTab {
case .tables:
TableListView(
Expand Down Expand Up @@ -301,7 +288,8 @@ struct ConnectedView: View {
activeSchema = name
self.tables = try await session.driver.fetchTables(schema: name)
} catch {
withAnimation { toastMessage = String(localized: "Failed to switch schema") }
failureAlertMessage = String(localized: "Failed to switch schema")
showFailureAlert = true
}
}

Expand All @@ -318,7 +306,8 @@ struct ConnectedView: View {
activeDatabase = name
self.tables = try await session.driver.fetchTables(schema: nil)
} catch {
withAnimation { toastMessage = String(localized: "Failed to switch database") }
failureAlertMessage = String(localized: "Failed to switch database")
showFailureAlert = true
}
}
}
Expand Down Expand Up @@ -346,7 +335,8 @@ struct ConnectedView: View {
let fallbackSession = try await appState.connectionManager.connect(connection)
self.session = fallbackSession
self.tables = try await fallbackSession.driver.fetchTables(schema: nil)
withAnimation { toastMessage = String(localized: "Failed to switch database") }
failureAlertMessage = String(localized: "Failed to switch database")
showFailureAlert = true
} catch {
// Both failed — show error view
let context = ErrorContext(
Expand All @@ -368,7 +358,8 @@ struct ConnectedView: View {
self.tables = try await session.driver.fetchTables(schema: schema)
} catch {
Self.logger.warning("Failed to refresh tables: \(error.localizedDescription, privacy: .public)")
withAnimation { toastMessage = String(localized: "Failed to refresh tables") }
failureAlertMessage = String(localized: "Failed to refresh tables")
showFailureAlert = true
}
}
}
33 changes: 32 additions & 1 deletion TableProMobile/TableProMobile/Views/DataBrowserView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,19 @@ struct DataBrowserView: View {
row: row
)
}
.contextMenu {
Menu("Copy Row") {
ForEach(ExportFormat.allCases) { format in
Button(format.rawValue) {
let text = ClipboardExporter.exportRow(
columns: columns, row: rows[index],
format: format, tableName: table.name
)
ClipboardExporter.copyToClipboard(text)
}
}
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
if !isView && hasPrimaryKeys {
Button(role: .destructive) {
Expand All @@ -165,7 +178,7 @@ struct DataBrowserView: View {
}
}
}
.listStyle(.insetGrouped)
.listStyle(.plain)
.opacity(isPageLoading ? 0.5 : 1)
.allowsHitTesting(!isPageLoading)
.overlay { if isPageLoading { ProgressView() } }
Expand All @@ -177,6 +190,24 @@ struct DataBrowserView: View {

@ToolbarContentBuilder
private var topToolbar: some ToolbarContent {
ToolbarItem(placement: .topBarTrailing) {
Menu {
ForEach(ExportFormat.allCases) { format in
Button {
let text = ClipboardExporter.exportRows(
columns: columns, rows: rows,
format: format, tableName: table.name
)
ClipboardExporter.copyToClipboard(text)
} label: {
Label(format.rawValue, systemImage: "doc.on.clipboard")
}
}
} label: {
Image(systemName: "square.and.arrow.up")
}
.disabled(rows.isEmpty)
}
ToolbarItem(placement: .topBarTrailing) {
Button { showFilterSheet = true } label: {
Image(systemName: hasActiveFilters
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ struct GroupManagementView: View {
.navigationTitle("Groups")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
}
ToolbarItemGroup(placement: .topBarTrailing) {
Expand Down
9 changes: 4 additions & 5 deletions TableProMobile/TableProMobile/Views/InsertRowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -136,11 +136,10 @@ struct InsertRowView: View {
.alert(operationError?.title ?? "Error", isPresented: $showOperationError) {
Button("OK", role: .cancel) {}
} message: {
VStack {
Text(operationError?.message ?? "An unknown error occurred.")
if let recovery = operationError?.recovery {
Text(verbatim: recovery)
}
if let recovery = operationError?.recovery {
Text("\(operationError?.message ?? "")\n\(recovery)")
} else {
Text(operationError?.message ?? "")
}
}
}
Expand Down
2 changes: 0 additions & 2 deletions TableProMobile/TableProMobile/Views/OnboardingView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,6 @@ struct OnboardingView: View {
description: String(localized: "Import connections from your Mac")
)
}
.buttonStyle(.plain)

Button(action: addNewConnection) {
actionCard(
Expand All @@ -92,7 +91,6 @@ struct OnboardingView: View {
description: String(localized: "Set up a new database connection")
)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 24)

Expand Down
Loading
Loading