diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a9e90619..3148862fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Display BLOB data as multi-line hex dump in detail view sidebar - SQL Favorites: save and organize frequently used queries with optional keyword bindings for autocomplete expansion - Copy selected rows as JSON from context menu and Edit menu - iCloud Sync (Pro): sync connections, groups, tags, settings, and query history across Macs via CloudKit diff --git a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift index 184b89f57..f39a50aeb 100644 --- a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift +++ b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift @@ -173,8 +173,17 @@ private actor SQLiteConnectionActor { var row: [String?] = [] for i in 0.. 0, let blobPtr = sqlite3_column_blob(statement, i) { + let data = Data(bytes: blobPtr, count: byteCount) + row.append(String(data: data, encoding: .isoLatin1) ?? "") + } else { + row.append("") + } } else if let text = sqlite3_column_text(statement, i) { row.append(String(cString: text)) } else { @@ -273,8 +282,17 @@ private actor SQLiteConnectionActor { var row: [String?] = [] for i in 0.. 0, let blobPtr = sqlite3_column_blob(statement, i) { + let data = Data(bytes: blobPtr, count: byteCount) + row.append(String(data: data, encoding: .isoLatin1) ?? "") + } else { + row.append("") + } } else if let text = sqlite3_column_text(statement, i) { row.append(String(cString: text)) } else { diff --git a/TablePro/Core/Services/ColumnType.swift b/TablePro/Core/Services/ColumnType.swift index c36edf516..a1e59a3ac 100644 --- a/TablePro/Core/Services/ColumnType.swift +++ b/TablePro/Core/Services/ColumnType.swift @@ -123,6 +123,13 @@ enum ColumnType: Equatable { } } + var isBlobType: Bool { + switch self { + case .blob: return true + default: return false + } + } + /// Compact lowercase badge label for sidebar var badgeLabel: String { switch self { diff --git a/TablePro/Core/Services/Formatting/BlobFormattingService.swift b/TablePro/Core/Services/Formatting/BlobFormattingService.swift new file mode 100644 index 000000000..5c5651390 --- /dev/null +++ b/TablePro/Core/Services/Formatting/BlobFormattingService.swift @@ -0,0 +1,82 @@ +// +// BlobFormattingService.swift +// TablePro +// +// Centralized BLOB formatting service for binary data display. +// + +import Foundation + +/// Display context for BLOB formatting. +enum BlobDisplayContext { + /// Data grid cell: compact single-line "0x48656C6C6F..." + case grid + /// Sidebar detail view: full multi-line hex dump + case detail + /// Copy to clipboard: compact hex + case copy + /// Editable hex in sidebar: space-separated hex bytes "48 65 6C 6C 6F" + case edit +} + +@MainActor +final class BlobFormattingService { + static let shared = BlobFormattingService() + + private init() {} + + /// Format a raw BLOB string value for the given display context. + func format(_ value: String, for context: BlobDisplayContext) -> String? { + switch context { + case .grid, .copy: + return value.formattedAsCompactHex() + case .detail: + return value.formattedAsHexDump() + case .edit: + return value.formattedAsEditableHex() + } + } + + /// Parse an edited hex string back to a raw binary string. + /// Accepts space-separated hex bytes (e.g., "48 65 6C 6C 6F") or continuous hex (e.g., "48656C6C6F"). + /// Returns nil if the hex string is invalid. + func parseHex(_ hexString: String) -> String? { + var cleaned = hexString.trimmingCharacters(in: .whitespacesAndNewlines) + if cleaned.hasPrefix("0x") || cleaned.hasPrefix("0X") { + cleaned = String(cleaned.dropFirst(2)) + } + cleaned = cleaned.replacingOccurrences(of: " ", with: "") + cleaned = cleaned.replacingOccurrences(of: "\n", with: "") + cleaned = cleaned.replacingOccurrences(of: "\t", with: "") + + guard !cleaned.isEmpty, cleaned.count % 2 == 0 else { return nil } + + var bytes: [UInt8] = [] + bytes.reserveCapacity(cleaned.count / 2) + + var index = cleaned.startIndex + while index < cleaned.endIndex { + let nextIndex = cleaned.index(index, offsetBy: 2) + let byteString = cleaned[index.. Bool { + columnType.isBlobType + } + + /// Format a value if the column type is a BLOB type; otherwise return the original value. + func formatIfNeeded(_ value: String, columnType: ColumnType?, for context: BlobDisplayContext) -> String { + guard let columnType, requiresFormatting(columnType: columnType) else { + return value + } + return format(value, for: context) ?? value + } +} diff --git a/TablePro/Extensions/String+HexDump.swift b/TablePro/Extensions/String+HexDump.swift new file mode 100644 index 000000000..568547232 --- /dev/null +++ b/TablePro/Extensions/String+HexDump.swift @@ -0,0 +1,126 @@ +// +// String+HexDump.swift +// TablePro +// +// Hex dump formatting utilities for binary data display. +// + +import Foundation + +extension String { + /// Returns a classic hex dump representation of this string's bytes, or nil if empty. + /// + /// Format per line: `OFFSET HH HH HH HH HH HH HH HH HH HH HH HH HH HH HH HH |ASCII...........|` + /// - Parameter maxBytes: Maximum bytes to display before truncating (default 10KB). + func formattedAsHexDump(maxBytes: Int = 10_240) -> String? { + // Convert to bytes: try isoLatin1 first (matches plugin fallback encoding for non-UTF-8 data), + // then utf8 + guard let bytes = data(using: .isoLatin1) ?? data(using: .utf8) else { + return nil + } + + let totalCount = bytes.count + guard totalCount > 0 else { return nil } + + let displayCount = min(totalCount, maxBytes) + let bytesArray = [UInt8](bytes.prefix(displayCount)) + + var lines: [String] = [] + lines.reserveCapacity(displayCount / 16 + 2) + + let bytesPerLine = 16 + var offset = 0 + + while offset < displayCount { + let lineEnd = min(offset + bytesPerLine, displayCount) + let lineBytes = bytesArray[offset..= 0x20, byte <= 0x7E { + line += String(UnicodeScalar(byte)) + } else { + line += "." + } + } + line += "|" + + lines.append(line) + offset += bytesPerLine + } + + if totalCount > maxBytes { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + let formattedTotal = formatter.string(from: NSNumber(value: totalCount)) ?? "\(totalCount)" + lines.append("... (truncated, \(formattedTotal) bytes total)") + } + + return lines.joined(separator: "\n") + } + + /// Returns a space-separated hex representation suitable for editing. + /// + /// Format: `48 65 6C 6C 6F` — one hex byte pair separated by spaces, no offset or ASCII columns. + /// - Parameter maxBytes: Maximum bytes to display before truncating (default 10KB). + func formattedAsEditableHex(maxBytes: Int = 10_240) -> String? { + guard let bytes = data(using: .isoLatin1) ?? data(using: .utf8) else { + return nil + } + + let totalCount = bytes.count + guard totalCount > 0 else { return nil } + + let displayCount = min(totalCount, maxBytes) + let bytesArray = [UInt8](bytes.prefix(displayCount)) + + var hex = bytesArray.map { String(format: "%02X", $0) }.joined(separator: " ") + + if totalCount > maxBytes { + hex += " …" + } + + return hex + } + + /// Returns a compact single-line hex representation for data grid cells. + /// + /// Format: `0x48656C6C6F` for short values, truncated with `…` for longer ones. + /// - Parameter maxBytes: Maximum bytes to show before truncating (default 64). + func formattedAsCompactHex(maxBytes: Int = 64) -> String? { + guard let bytes = data(using: .isoLatin1) ?? data(using: .utf8) else { + return nil + } + + let totalCount = bytes.count + guard totalCount > 0 else { return nil } + + let displayCount = min(totalCount, maxBytes) + let bytesArray = [UInt8](bytes.prefix(displayCount)) + + var hex = "0x" + for byte in bytesArray { + hex += String(format: "%02X", byte) + } + + if totalCount > maxBytes { + hex += "…" + } + + return hex + } +} diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index e28303762..d5ab0b8d1 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -655,6 +655,9 @@ } } } + }, + "%lld bytes" : { + }, "%lld in · %lld out" : { "localizations" : { @@ -4665,6 +4668,9 @@ } } } + }, + "Copy as Hex" : { + }, "Copy as JSON" : { @@ -8760,6 +8766,9 @@ } } } + }, + "Hex bytes" : { + }, "Hide All" : { "localizations" : { diff --git a/TablePro/Views/Results/DataGridCellFactory.swift b/TablePro/Views/Results/DataGridCellFactory.swift index 8e1d139a1..d36c04751 100644 --- a/TablePro/Views/Results/DataGridCellFactory.swift +++ b/TablePro/Views/Results/DataGridCellFactory.swift @@ -329,9 +329,13 @@ final class DataGridCellFactory { } else { var displayValue = value ?? "" - if let columnType = columnType, columnType.isDateType, !displayValue.isEmpty { - if let formattedDate = DateFormattingService.shared.format(dateString: displayValue) { - displayValue = formattedDate + if let columnType = columnType, !displayValue.isEmpty { + if columnType.isDateType { + if let formattedDate = DateFormattingService.shared.format(dateString: displayValue) { + displayValue = formattedDate + } + } else if BlobFormattingService.shared.requiresFormatting(columnType: columnType) { + displayValue = BlobFormattingService.shared.formatIfNeeded(displayValue, columnType: columnType, for: .grid) } } diff --git a/TablePro/Views/Results/DataGridView+RowActions.swift b/TablePro/Views/Results/DataGridView+RowActions.swift index 6298b9d9a..1728af50b 100644 --- a/TablePro/Views/Results/DataGridView+RowActions.swift +++ b/TablePro/Views/Results/DataGridView+RowActions.swift @@ -33,11 +33,12 @@ extension TableViewCoordinator { func copyRows(at indices: Set) { let sortedIndices = indices.sorted() + let columnTypes = (rowProvider as? InMemoryRowProvider)?.columnTypes var lines: [String] = [] for index in sortedIndices { guard let values = rowProvider.rowValues(at: index) else { continue } - let line = values.map { $0 ?? "NULL" }.joined(separator: "\t") + let line = formatRowForCopy(values: values, columnTypes: columnTypes) lines.append(line) } @@ -47,6 +48,7 @@ extension TableViewCoordinator { func copyRowsWithHeaders(at indices: Set) { let sortedIndices = indices.sorted() + let columnTypes = (rowProvider as? InMemoryRowProvider)?.columnTypes var lines: [String] = [] // Add header row @@ -54,7 +56,7 @@ extension TableViewCoordinator { for index in sortedIndices { guard let values = rowProvider.rowValues(at: index) else { continue } - let line = values.map { $0 ?? "NULL" }.joined(separator: "\t") + let line = formatRowForCopy(values: values, columnTypes: columnTypes) lines.append(line) } @@ -100,7 +102,10 @@ extension TableViewCoordinator { guard columnIndex >= 0 && columnIndex < rowProvider.columns.count else { return } let value = rowProvider.value(atRow: rowIndex, column: columnIndex) ?? "NULL" - ClipboardService.shared.writeText(value) + let columnTypes = (rowProvider as? InMemoryRowProvider)?.columnTypes + let columnType = columnTypes.flatMap { $0.indices.contains(columnIndex) ? $0[columnIndex] : nil } + let copyValue = BlobFormattingService.shared.formatIfNeeded(value, columnType: columnType, for: .copy) + ClipboardService.shared.writeText(copyValue) } func copyRowsAsInsert(at indices: Set) { @@ -144,6 +149,14 @@ extension TableViewCoordinator { ClipboardService.shared.writeText(converter.generateJson(rows: rows)) } + private func formatRowForCopy(values: [String?], columnTypes: [ColumnType]?) -> String { + values.enumerated().map { index, value in + guard let value else { return "NULL" } + let columnType = columnTypes.flatMap { $0.indices.contains(index) ? $0[index] : nil } + return BlobFormattingService.shared.formatIfNeeded(value, columnType: columnType, for: .copy) + }.joined(separator: "\t") + } + private func resolveDriver() -> (any DatabaseDriver)? { guard let connectionId else { return nil } return DatabaseManager.shared.driver(for: connectionId) diff --git a/TablePro/Views/Results/KeyHandlingTableView.swift b/TablePro/Views/Results/KeyHandlingTableView.swift index e3ac2e51f..b74fb025e 100644 --- a/TablePro/Views/Results/KeyHandlingTableView.swift +++ b/TablePro/Views/Results/KeyHandlingTableView.swift @@ -127,8 +127,11 @@ final class KeyHandlingTableView: NSTableView { /// Copy selected rows to clipboard @objc func copy(_ sender: Any?) { + let indices = Set(selectedRowIndexes) if let callback = coordinator?.onCopyRows { - callback(Set(selectedRowIndexes)) + callback(indices) + } else { + coordinator?.copyRows(at: indices) } } diff --git a/TablePro/Views/RightSidebar/EditableFieldView.swift b/TablePro/Views/RightSidebar/EditableFieldView.swift index 86895a742..c842d76be 100644 --- a/TablePro/Views/RightSidebar/EditableFieldView.swift +++ b/TablePro/Views/RightSidebar/EditableFieldView.swift @@ -28,6 +28,7 @@ struct EditableFieldView: View { @FocusState private var isFocused: Bool @State private var isHovered = false @State private var isSetPopoverPresented = false + @State private var hexEditText = "" private var placeholderText: String { if hasMultipleValues { @@ -92,6 +93,8 @@ struct EditableFieldView: View { setPicker(values: values) } else if columnTypeEnum.isBooleanType { booleanPicker + } else if BlobFormattingService.shared.requiresFormatting(columnType: columnTypeEnum) { + blobHexEditor } else if isLongText || columnTypeEnum.isJsonType { multiLineEditor } else { @@ -99,6 +102,51 @@ struct EditableFieldView: View { } } + private var blobHexEditor: some View { + VStack(alignment: .leading, spacing: 2) { + TextField("Hex bytes", text: $hexEditText, axis: .vertical) + .textFieldStyle(.roundedBorder) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.tiny, design: .monospaced)) + .lineLimit(3...8) + .focused($isFocused) + .onAppear { + hexEditText = BlobFormattingService.shared.format(value, for: .edit) ?? "" + } + .onChange(of: value) { + if !isFocused { + hexEditText = BlobFormattingService.shared.format(value, for: .edit) ?? "" + } + } + .onChange(of: isFocused) { + if !isFocused { + commitHexEdit() + } + } + + HStack(spacing: 4) { + if let byteCount = value.data(using: .isoLatin1)?.count, byteCount > 0 { + Text("\(byteCount) bytes") + .font(.system(size: ThemeEngine.shared.activeTheme.typography.tiny)) + .foregroundStyle(.tertiary) + } + + if BlobFormattingService.shared.parseHex(hexEditText) == nil, !hexEditText.isEmpty { + Text("Invalid hex") + .font(.system(size: ThemeEngine.shared.activeTheme.typography.tiny)) + .foregroundStyle(.red) + } + } + } + } + + private func commitHexEdit() { + if let raw = BlobFormattingService.shared.parseHex(hexEditText) { + value = raw + } else { + hexEditText = BlobFormattingService.shared.format(value, for: .edit) ?? "" + } + } + private var booleanPicker: some View { dropdownField(label: normalizeBooleanValue(value) == "1" ? "true" : "false") { Button("true") { value = "1" } @@ -213,6 +261,14 @@ struct EditableFieldView: View { } } + if BlobFormattingService.shared.requiresFormatting(columnType: columnTypeEnum) { + Button("Copy as Hex") { + if let hex = BlobFormattingService.shared.format(value, for: .detail) { + ClipboardService.shared.writeText(hex) + } + } + } + Button("Copy Value") { ClipboardService.shared.writeText(value) } @@ -284,7 +340,15 @@ struct ReadOnlyFieldView: View { // Line 2: value in disabled native text field if let value { - if isLongText { + if BlobFormattingService.shared.requiresFormatting(columnType: columnTypeEnum) { + ScrollView { + Text(BlobFormattingService.shared.formatIfNeeded(value, columnType: columnTypeEnum, for: .detail)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.tiny, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .frame(maxHeight: 120) + } else if isLongText { Text(value) .font(.system(size: ThemeEngine.shared.activeTheme.typography.small, design: .monospaced)) .textSelection(.enabled) @@ -307,6 +371,14 @@ struct ReadOnlyFieldView: View { Button("Copy Value") { ClipboardService.shared.writeText(value) } + + if BlobFormattingService.shared.requiresFormatting(columnType: columnTypeEnum) { + Button("Copy as Hex") { + if let hex = BlobFormattingService.shared.format(value, for: .detail) { + ClipboardService.shared.writeText(hex) + } + } + } } } } diff --git a/TableProTests/Extensions/StringHexDumpTests.swift b/TableProTests/Extensions/StringHexDumpTests.swift new file mode 100644 index 000000000..7db5f872e --- /dev/null +++ b/TableProTests/Extensions/StringHexDumpTests.swift @@ -0,0 +1,190 @@ +// +// StringHexDumpTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("String+HexDump") +struct StringHexDumpTests { + // MARK: - Hex Dump + + @Test("Empty string returns nil") + func emptyStringReturnsNil() { + #expect("".formattedAsHexDump() == nil) + } + + @Test("Basic ASCII produces correct hex and ASCII column") + func basicASCII() { + let result = "Hello".formattedAsHexDump() + #expect(result != nil) + #expect(result?.contains("48 65 6C 6C 6F") == true) + #expect(result?.contains("|Hello|") == true) + } + + @Test("Full 16-byte line has correct offset and ASCII") + func fullLine() { + let result = "0123456789ABCDEF".formattedAsHexDump() + #expect(result?.hasPrefix("00000000") == true) + #expect(result?.contains("|0123456789ABCDEF|") == true) + } + + @Test("Multiple lines have correct offsets") + func multipleLines() { + let result = "ABCDEFGHIJKLMNOPQRST".formattedAsHexDump() + let lines = result?.split(separator: "\n") ?? [] + #expect(lines.count == 2) + #expect(lines[0].hasPrefix("00000000")) + #expect(lines[1].hasPrefix("00000010")) + } + + @Test("Non-printable characters show as dots in ASCII column") + func nonPrintableCharsShowAsDots() { + let bytes: [UInt8] = [0x00, 0x01, 0x02, 0x41, 0x42, 0x7F, 0xFF] + guard let input = String(bytes: bytes, encoding: .isoLatin1) else { + Issue.record("Failed to create isoLatin1 string") + return + } + let result = input.formattedAsHexDump() + #expect(result?.contains("|...AB..|") == true) + } + + @Test("Truncation adds summary line") + func truncation() { + let input = String(repeating: "A", count: 100) + let result = input.formattedAsHexDump(maxBytes: 32) + #expect(result?.contains("truncated") == true) + #expect(result?.contains("100 bytes total") == true) + let lines = result?.split(separator: "\n") ?? [] + #expect(lines.count == 3) + } + + @Test("Offset formatting across multiple lines") + func offsetFormatting() { + let input = String(repeating: "X", count: 48) + let lines = input.formattedAsHexDump()?.split(separator: "\n") ?? [] + #expect(lines.count == 3) + #expect(lines[0].hasPrefix("00000000")) + #expect(lines[1].hasPrefix("00000010")) + #expect(lines[2].hasPrefix("00000020")) + } + + @Test("Single byte") + func singleByte() { + let result = "A".formattedAsHexDump() + #expect(result?.contains("41") == true) + #expect(result?.contains("|A|") == true) + } + + // MARK: - Compact Hex + + @Test("Compact hex basic ASCII") + func compactHexBasic() { + #expect("Hello".formattedAsCompactHex() == "0x48656C6C6F") + } + + @Test("Compact hex empty string returns nil") + func compactHexEmpty() { + #expect("".formattedAsCompactHex() == nil) + } + + @Test("Compact hex truncation adds ellipsis") + func compactHexTruncation() { + let input = String(repeating: "A", count: 100) + #expect(input.formattedAsCompactHex(maxBytes: 4) == "0x41414141…") + } + + @Test("Compact hex non-printable bytes") + func compactHexNonPrintable() { + let bytes: [UInt8] = [0x00, 0xFF] + guard let input = String(bytes: bytes, encoding: .isoLatin1) else { + Issue.record("Failed to create isoLatin1 string") + return + } + #expect(input.formattedAsCompactHex() == "0x00FF") + } + + // MARK: - Editable Hex + + @Test("Editable hex basic ASCII") + func editableHexBasic() { + #expect("Hello".formattedAsEditableHex() == "48 65 6C 6C 6F") + } + + @Test("Editable hex empty string returns nil") + func editableHexEmpty() { + #expect("".formattedAsEditableHex() == nil) + } + + @Test("Editable hex non-printable bytes") + func editableHexNonPrintable() { + let bytes: [UInt8] = [0x00, 0x01, 0xFF] + guard let input = String(bytes: bytes, encoding: .isoLatin1) else { + Issue.record("Failed to create isoLatin1 string") + return + } + #expect(input.formattedAsEditableHex() == "00 01 FF") + } + + @Test("Editable hex truncation adds ellipsis") + func editableHexTruncation() { + let input = String(repeating: "A", count: 100) + let result = input.formattedAsEditableHex(maxBytes: 3) + #expect(result?.hasPrefix("41 41 41") == true) + #expect(result?.hasSuffix("…") == true) + } + + // MARK: - Parse Hex + + @Test("Parse space-separated hex") + @MainActor + func parseHexSpaceSeparated() { + #expect(BlobFormattingService.shared.parseHex("48 65 6C 6C 6F") == "Hello") + } + + @Test("Parse continuous hex") + @MainActor + func parseHexContinuous() { + #expect(BlobFormattingService.shared.parseHex("48656C6C6F") == "Hello") + } + + @Test("Parse hex with 0x prefix") + @MainActor + func parseHexWithPrefix() { + #expect(BlobFormattingService.shared.parseHex("0x48656C6C6F") == "Hello") + } + + @Test("Parse hex rejects odd-length input") + @MainActor + func parseHexInvalidOddLength() { + #expect(BlobFormattingService.shared.parseHex("486") == nil) + } + + @Test("Parse hex rejects invalid characters") + @MainActor + func parseHexInvalidChars() { + #expect(BlobFormattingService.shared.parseHex("ZZZZ") == nil) + } + + @Test("Parse hex rejects empty string") + @MainActor + func parseHexEmpty() { + #expect(BlobFormattingService.shared.parseHex("") == nil) + } + + @Test("Round-trip: raw → editable hex → parse back to raw") + @MainActor + func parseHexRoundTrip() { + let bytes: [UInt8] = [0x00, 0x01, 0x7F, 0x80, 0xFF] + guard let original = String(bytes: bytes, encoding: .isoLatin1), + let hex = original.formattedAsEditableHex(), + let roundTripped = BlobFormattingService.shared.parseHex(hex) else { + Issue.record("Round-trip conversion failed") + return + } + #expect(roundTripped == original) + } +}