diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d9de364..f79df382 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/TableProMobile/TableProMobile/Helpers/ClipboardExporter.swift b/TableProMobile/TableProMobile/Helpers/ClipboardExporter.swift new file mode 100644 index 00000000..bfc1d4b3 --- /dev/null +++ b/TableProMobile/TableProMobile/Helpers/ClipboardExporter.swift @@ -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") + } +} diff --git a/TableProMobile/TableProMobile/Views/Components/ConnectionColorPicker.swift b/TableProMobile/TableProMobile/Views/Components/ConnectionColorPicker.swift index ad9ffcaf..5ec99ed8 100644 --- a/TableProMobile/TableProMobile/Views/Components/ConnectionColorPicker.swift +++ b/TableProMobile/TableProMobile/Views/Components/ConnectionColorPicker.swift @@ -31,6 +31,7 @@ struct ConnectionColorPicker: View { .contentShape(Circle()) } .buttonStyle(.plain) + .accessibilityLabel(Text(color.rawValue)) } } .padding(.vertical, 4) diff --git a/TableProMobile/TableProMobile/Views/ConnectedView.swift b/TableProMobile/TableProMobile/Views/ConnectedView.swift index 903aaa74..05cf2303 100644 --- a/TableProMobile/TableProMobile/Views/ConnectedView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectedView.swift @@ -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? + @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() @@ -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 { @@ -114,7 +110,7 @@ struct ConnectedView: View { } label: { HStack(spacing: 4) { Text(activeDatabase) - .font(.headline) + .font(.subheadline) if isSwitching { ProgressView() .controlSize(.mini) @@ -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( @@ -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 } } @@ -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 } } } @@ -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( @@ -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 } } } diff --git a/TableProMobile/TableProMobile/Views/DataBrowserView.swift b/TableProMobile/TableProMobile/Views/DataBrowserView.swift index 9c802869..4589725c 100644 --- a/TableProMobile/TableProMobile/Views/DataBrowserView.swift +++ b/TableProMobile/TableProMobile/Views/DataBrowserView.swift @@ -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) { @@ -165,7 +178,7 @@ struct DataBrowserView: View { } } } - .listStyle(.insetGrouped) + .listStyle(.plain) .opacity(isPageLoading ? 0.5 : 1) .allowsHitTesting(!isPageLoading) .overlay { if isPageLoading { ProgressView() } } @@ -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 diff --git a/TableProMobile/TableProMobile/Views/GroupManagementView.swift b/TableProMobile/TableProMobile/Views/GroupManagementView.swift index 89f17634..f1741429 100644 --- a/TableProMobile/TableProMobile/Views/GroupManagementView.swift +++ b/TableProMobile/TableProMobile/Views/GroupManagementView.swift @@ -64,7 +64,7 @@ struct GroupManagementView: View { .navigationTitle("Groups") .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement: .topBarLeading) { + ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } ToolbarItemGroup(placement: .topBarTrailing) { diff --git a/TableProMobile/TableProMobile/Views/InsertRowView.swift b/TableProMobile/TableProMobile/Views/InsertRowView.swift index 10c0376b..c6f1acf2 100644 --- a/TableProMobile/TableProMobile/Views/InsertRowView.swift +++ b/TableProMobile/TableProMobile/Views/InsertRowView.swift @@ -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 ?? "") } } } diff --git a/TableProMobile/TableProMobile/Views/OnboardingView.swift b/TableProMobile/TableProMobile/Views/OnboardingView.swift index dbfbd1c6..a859c3f2 100644 --- a/TableProMobile/TableProMobile/Views/OnboardingView.swift +++ b/TableProMobile/TableProMobile/Views/OnboardingView.swift @@ -82,7 +82,6 @@ struct OnboardingView: View { description: String(localized: "Import connections from your Mac") ) } - .buttonStyle(.plain) Button(action: addNewConnection) { actionCard( @@ -92,7 +91,6 @@ struct OnboardingView: View { description: String(localized: "Set up a new database connection") ) } - .buttonStyle(.plain) } .padding(.horizontal, 24) diff --git a/TableProMobile/TableProMobile/Views/QueryEditorView.swift b/TableProMobile/TableProMobile/Views/QueryEditorView.swift index b7fe06b0..42459b21 100644 --- a/TableProMobile/TableProMobile/Views/QueryEditorView.swift +++ b/TableProMobile/TableProMobile/Views/QueryEditorView.swift @@ -25,6 +25,7 @@ struct QueryEditorView: View { let connectionId: UUID let historyStorage: QueryHistoryStorage @State private var showHistory = false + @State private var showClearHistoryConfirmation = false @FocusState private var editorFocused: Bool var body: some View { @@ -134,46 +135,70 @@ struct QueryEditorView: View { } } - // Native iOS pattern: List with rows, each row shows column:value pairs private func resultList(_ result: QueryResult) -> some View { List { ForEach(Array(result.rows.enumerated()), id: \.offset) { rowIndex, row in - Section { - ForEach(Array(result.columns.enumerated()), id: \.offset) { colIndex, col in - let value = colIndex < row.count ? row[colIndex] : nil - HStack(alignment: .top) { - Text(verbatim: col.name) - .font(.caption) - .foregroundStyle(.secondary) - .frame(width: 90, alignment: .leading) - Text(verbatim: value ?? "NULL") - .font(.system(.body, design: .monospaced)) - .foregroundStyle(value == nil ? .secondary : .primary) - .frame(maxWidth: .infinity, alignment: .leading) - .lineLimit(3) - .textSelection(.enabled) - } - .contextMenu { - if let value { - Button { - UIPasteboard.general.string = value - } label: { - Label("Copy Value", systemImage: "doc.on.doc") - } - } - Button { - UIPasteboard.general.string = col.name - } label: { - Label("Copy Column Name", systemImage: "textformat") - } - } - } - } header: { - Text(verbatim: "Row \(rowIndex + 1)") + NavigationLink { + RowDetailView( + columns: result.columns, + rows: result.rows, + initialIndex: rowIndex + ) + } label: { + resultRowCard(columns: result.columns, row: row) + } + .contextMenu { + resultRowContextMenu(columns: result.columns, row: row) + } + } + } + .listStyle(.plain) + } + + private func resultRowCard(columns: [ColumnInfo], row: [String?]) -> some View { + let preview = Array(zip(columns, row).prefix(4)) + return VStack(alignment: .leading, spacing: 4) { + ForEach(Array(preview.enumerated()), id: \.offset) { index, pair in + HStack(spacing: 6) { + Text(pair.0.name) + .font(.caption2) + .foregroundStyle(.tertiary) + Text(verbatim: pair.1 ?? "NULL") + .font(index == 0 ? .subheadline : .caption) + .fontWeight(index == 0 ? .medium : .regular) + .foregroundStyle(pair.1 == nil ? .secondary : .primary) + .lineLimit(1) + } + } + if columns.count > 4 { + Text("+\(columns.count - 4) more columns") + .font(.caption2) + .foregroundStyle(.quaternary) + } + } + .padding(.vertical, 2) + } + + @ViewBuilder + private func resultRowContextMenu(columns: [ColumnInfo], row: [String?]) -> some View { + if let firstValue = row.first, let value = firstValue { + Button { + UIPasteboard.general.string = value + } label: { + Label("Copy Value", systemImage: "doc.on.doc") + } + } + Menu("Copy Row") { + ForEach(ExportFormat.allCases) { format in + Button(format.rawValue) { + let text = ClipboardExporter.exportRow( + columns: columns, row: row, + format: format + ) + ClipboardExporter.copyToClipboard(text) } } } - .listStyle(.insetGrouped) } // MARK: - Toolbar @@ -215,6 +240,22 @@ struct QueryEditorView: View { } } + if let result, !result.rows.isEmpty { + Section("Copy Results") { + ForEach(ExportFormat.allCases) { format in + Button { + let text = ClipboardExporter.exportRows( + columns: result.columns, rows: result.rows, + format: format + ) + ClipboardExporter.copyToClipboard(text) + } label: { + Label(format.rawValue, systemImage: "doc.on.clipboard") + } + } + } + } + Divider() Button(role: .destructive) { @@ -259,23 +300,29 @@ struct QueryEditorView: View { } queryHistory = historyStorage.load(for: connectionId) } + + if !queryHistory.isEmpty { + Section { + Button("Clear All History", role: .destructive) { + showClearHistoryConfirmation = true + } + } + } } .listStyle(.insetGrouped) .navigationTitle("Query History") .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement: .cancellationAction) { - if !queryHistory.isEmpty { - Button("Clear All", role: .destructive) { - historyStorage.clearAll(for: connectionId) - queryHistory = [] - } - } - } ToolbarItem(placement: .confirmationAction) { Button("Done") { showHistory = false } } } + .confirmationDialog("Clear History", isPresented: $showClearHistoryConfirmation) { + Button("Clear All", role: .destructive) { + historyStorage.clearAll(for: connectionId) + queryHistory = [] + } + } .overlay { if queryHistory.isEmpty { ContentUnavailableView( diff --git a/TableProMobile/TableProMobile/Views/RowDetailView.swift b/TableProMobile/TableProMobile/Views/RowDetailView.swift index 235661fa..19ea4c18 100644 --- a/TableProMobile/TableProMobile/Views/RowDetailView.swift +++ b/TableProMobile/TableProMobile/Views/RowDetailView.swift @@ -128,6 +128,24 @@ struct RowDetailView: View { .navigationTitle(String(format: String(localized: "Row %d of %d"), currentIndex + 1, rows.count)) .navigationBarTitleDisplayMode(.inline) .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Menu { + ForEach(ExportFormat.allCases) { format in + Button { + let text = ClipboardExporter.exportRow( + columns: columns, row: currentRow, + format: format, tableName: table?.name + ) + ClipboardExporter.copyToClipboard(text) + } label: { + Label(format.rawValue, systemImage: "doc.on.clipboard") + } + } + } label: { + Image(systemName: "square.and.arrow.up") + } + } + ToolbarItem(placement: .primaryAction) { if canEdit { if isEditing { @@ -186,11 +204,10 @@ struct RowDetailView: 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 ?? "") } } } diff --git a/TableProMobile/TableProMobile/Views/StructureView.swift b/TableProMobile/TableProMobile/Views/StructureView.swift index 2a3ae1c9..8e15b018 100644 --- a/TableProMobile/TableProMobile/Views/StructureView.swift +++ b/TableProMobile/TableProMobile/Views/StructureView.swift @@ -236,9 +236,9 @@ struct StructureView: View { guard let session else { appError = AppError( category: .config, - title: "Not Connected", - message: "No active database session.", - recovery: "Go back and reconnect to the database.", + title: String(localized: "Not Connected"), + message: String(localized: "No active database session."), + recovery: String(localized: "Go back and reconnect to the database."), underlying: nil ) isLoading = false diff --git a/TableProMobile/TableProMobile/Views/TagManagementView.swift b/TableProMobile/TableProMobile/Views/TagManagementView.swift index 07ad766d..1b1d5364 100644 --- a/TableProMobile/TableProMobile/Views/TagManagementView.swift +++ b/TableProMobile/TableProMobile/Views/TagManagementView.swift @@ -66,7 +66,7 @@ struct TagManagementView: View { .navigationTitle("Tags") .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement: .topBarLeading) { + ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } ToolbarItemGroup(placement: .topBarTrailing) {