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

### 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
Expand Down
22 changes: 20 additions & 2 deletions Plugins/SQLiteDriverPlugin/SQLitePlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -173,8 +173,17 @@ private actor SQLiteConnectionActor {
var row: [String?] = []

for i in 0..<columnCount {
if sqlite3_column_type(statement, i) == SQLITE_NULL {
let colType = sqlite3_column_type(statement, i)
if colType == SQLITE_NULL {
row.append(nil)
} else if colType == SQLITE_BLOB {
let byteCount = Int(sqlite3_column_bytes(statement, i))
if byteCount > 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 {
Expand Down Expand Up @@ -273,8 +282,17 @@ private actor SQLiteConnectionActor {
var row: [String?] = []

for i in 0..<columnCount {
if sqlite3_column_type(statement, i) == SQLITE_NULL {
let colType = sqlite3_column_type(statement, i)
if colType == SQLITE_NULL {
row.append(nil)
} else if colType == SQLITE_BLOB {
let byteCount = Int(sqlite3_column_bytes(statement, i))
if byteCount > 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 {
Expand Down
7 changes: 7 additions & 0 deletions TablePro/Core/Services/ColumnType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
82 changes: 82 additions & 0 deletions TablePro/Core/Services/Formatting/BlobFormattingService.swift
Original file line number Diff line number Diff line change
@@ -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..<nextIndex]
guard let byte = UInt8(byteString, radix: 16) else { return nil }
bytes.append(byte)
index = nextIndex
}

let data = Data(bytes)
return String(data: data, encoding: .isoLatin1)
}

/// Whether the given column type requires BLOB formatting.
func requiresFormatting(columnType: ColumnType) -> 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
}
}
126 changes: 126 additions & 0 deletions TablePro/Extensions/String+HexDump.swift
Original file line number Diff line number Diff line change
@@ -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..<lineEnd]

// Offset column (8-digit hex)
var line = String(format: "%08X ", offset)

// Hex columns: two groups of 8 bytes
for i in 0..<bytesPerLine {
if i == 8 { line += " " }
if offset + i < lineEnd {
line += String(format: "%02X ", lineBytes[offset + i])
} else {
line += " "
}
}

// ASCII column
line += " |"
for byte in lineBytes {
if byte >= 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
}
}
9 changes: 9 additions & 0 deletions TablePro/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,9 @@
}
}
}
},
"%lld bytes" : {

},
"%lld in · %lld out" : {
"localizations" : {
Expand Down Expand Up @@ -4665,6 +4668,9 @@
}
}
}
},
"Copy as Hex" : {

},
"Copy as JSON" : {

Expand Down Expand Up @@ -8760,6 +8766,9 @@
}
}
}
},
"Hex bytes" : {

},
"Hide All" : {
"localizations" : {
Expand Down
10 changes: 7 additions & 3 deletions TablePro/Views/Results/DataGridCellFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
19 changes: 16 additions & 3 deletions TablePro/Views/Results/DataGridView+RowActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,12 @@ extension TableViewCoordinator {

func copyRows(at indices: Set<Int>) {
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)
}

Expand All @@ -47,14 +48,15 @@ extension TableViewCoordinator {

func copyRowsWithHeaders(at indices: Set<Int>) {
let sortedIndices = indices.sorted()
let columnTypes = (rowProvider as? InMemoryRowProvider)?.columnTypes
var lines: [String] = []

// Add header row
lines.append(rowProvider.columns.joined(separator: "\t"))

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)
}

Expand Down Expand Up @@ -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<Int>) {
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion TablePro/Views/Results/KeyHandlingTableView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
Loading
Loading