Skip to content

Commit 5778d5d

Browse files
committed
feat: display BLOB data as hex in grid and detail view (#333)
1 parent 13fb941 commit 5778d5d

11 files changed

Lines changed: 406 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- Display BLOB data as multi-line hex dump in detail view sidebar
1213
- SQL Favorites: save and organize frequently used queries with optional keyword bindings for autocomplete expansion
1314
- Copy selected rows as JSON from context menu and Edit menu
1415
- iCloud Sync (Pro): sync connections, groups, tags, settings, and query history across Macs via CloudKit

Plugins/SQLiteDriverPlugin/SQLitePlugin.swift

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,8 +173,17 @@ private actor SQLiteConnectionActor {
173173
var row: [String?] = []
174174

175175
for i in 0..<columnCount {
176-
if sqlite3_column_type(statement, i) == SQLITE_NULL {
176+
let colType = sqlite3_column_type(statement, i)
177+
if colType == SQLITE_NULL {
177178
row.append(nil)
179+
} else if colType == SQLITE_BLOB {
180+
let byteCount = Int(sqlite3_column_bytes(statement, i))
181+
if byteCount > 0, let blobPtr = sqlite3_column_blob(statement, i) {
182+
let data = Data(bytes: blobPtr, count: byteCount)
183+
row.append(String(data: data, encoding: .isoLatin1) ?? "")
184+
} else {
185+
row.append("")
186+
}
178187
} else if let text = sqlite3_column_text(statement, i) {
179188
row.append(String(cString: text))
180189
} else {
@@ -273,8 +282,17 @@ private actor SQLiteConnectionActor {
273282
var row: [String?] = []
274283

275284
for i in 0..<columnCount {
276-
if sqlite3_column_type(statement, i) == SQLITE_NULL {
285+
let colType = sqlite3_column_type(statement, i)
286+
if colType == SQLITE_NULL {
277287
row.append(nil)
288+
} else if colType == SQLITE_BLOB {
289+
let byteCount = Int(sqlite3_column_bytes(statement, i))
290+
if byteCount > 0, let blobPtr = sqlite3_column_blob(statement, i) {
291+
let data = Data(bytes: blobPtr, count: byteCount)
292+
row.append(String(data: data, encoding: .isoLatin1) ?? "")
293+
} else {
294+
row.append("")
295+
}
278296
} else if let text = sqlite3_column_text(statement, i) {
279297
row.append(String(cString: text))
280298
} else {

TablePro/Core/Services/ColumnType.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,13 @@ enum ColumnType: Equatable {
123123
}
124124
}
125125

126+
var isBlobType: Bool {
127+
switch self {
128+
case .blob: return true
129+
default: return false
130+
}
131+
}
132+
126133
/// Compact lowercase badge label for sidebar
127134
var badgeLabel: String {
128135
switch self {
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
//
2+
// BlobFormattingService.swift
3+
// TablePro
4+
//
5+
// Centralized BLOB formatting service for binary data display.
6+
//
7+
8+
import Foundation
9+
10+
/// Display context for BLOB formatting.
11+
enum BlobDisplayContext {
12+
/// Data grid cell: compact single-line "0x48656C6C6F..."
13+
case grid
14+
/// Sidebar detail view: full multi-line hex dump
15+
case detail
16+
/// Copy to clipboard: compact hex
17+
case copy
18+
/// Editable hex in sidebar: space-separated hex bytes "48 65 6C 6C 6F"
19+
case edit
20+
}
21+
22+
@MainActor
23+
final class BlobFormattingService {
24+
static let shared = BlobFormattingService()
25+
26+
private init() {}
27+
28+
/// Format a raw BLOB string value for the given display context.
29+
func format(_ value: String, for context: BlobDisplayContext) -> String? {
30+
switch context {
31+
case .grid, .copy:
32+
return value.formattedAsCompactHex()
33+
case .detail:
34+
return value.formattedAsHexDump()
35+
case .edit:
36+
return value.formattedAsEditableHex()
37+
}
38+
}
39+
40+
/// Parse an edited hex string back to a raw binary string.
41+
/// Accepts space-separated hex bytes (e.g., "48 65 6C 6C 6F") or continuous hex (e.g., "48656C6C6F").
42+
/// Returns nil if the hex string is invalid.
43+
func parseHex(_ hexString: String) -> String? {
44+
var cleaned = hexString.trimmingCharacters(in: .whitespacesAndNewlines)
45+
if cleaned.hasPrefix("0x") || cleaned.hasPrefix("0X") {
46+
cleaned = String(cleaned.dropFirst(2))
47+
}
48+
cleaned = cleaned.replacingOccurrences(of: " ", with: "")
49+
cleaned = cleaned.replacingOccurrences(of: "\n", with: "")
50+
cleaned = cleaned.replacingOccurrences(of: "\t", with: "")
51+
52+
guard !cleaned.isEmpty, cleaned.count % 2 == 0 else { return nil }
53+
54+
var bytes: [UInt8] = []
55+
bytes.reserveCapacity(cleaned.count / 2)
56+
57+
var index = cleaned.startIndex
58+
while index < cleaned.endIndex {
59+
let nextIndex = cleaned.index(index, offsetBy: 2)
60+
let byteString = cleaned[index..<nextIndex]
61+
guard let byte = UInt8(byteString, radix: 16) else { return nil }
62+
bytes.append(byte)
63+
index = nextIndex
64+
}
65+
66+
let data = Data(bytes)
67+
return String(data: data, encoding: .isoLatin1)
68+
}
69+
70+
/// Whether the given column type requires BLOB formatting.
71+
func requiresFormatting(columnType: ColumnType) -> Bool {
72+
columnType.isBlobType
73+
}
74+
75+
/// Format a value if the column type is a BLOB type; otherwise return the original value.
76+
func formatIfNeeded(_ value: String, columnType: ColumnType?, for context: BlobDisplayContext) -> String {
77+
guard let columnType, requiresFormatting(columnType: columnType) else {
78+
return value
79+
}
80+
return format(value, for: context) ?? value
81+
}
82+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
//
2+
// String+HexDump.swift
3+
// TablePro
4+
//
5+
// Hex dump formatting utilities for binary data display.
6+
//
7+
8+
import Foundation
9+
10+
extension String {
11+
/// Returns a classic hex dump representation of this string's bytes, or nil if empty.
12+
///
13+
/// Format per line: `OFFSET HH HH HH HH HH HH HH HH HH HH HH HH HH HH HH HH |ASCII...........|`
14+
/// - Parameter maxBytes: Maximum bytes to display before truncating (default 10KB).
15+
func formattedAsHexDump(maxBytes: Int = 10_240) -> String? {
16+
// Convert to bytes: try isoLatin1 first (matches plugin fallback encoding for non-UTF-8 data),
17+
// then utf8
18+
guard let bytes = data(using: .isoLatin1) ?? data(using: .utf8) else {
19+
return nil
20+
}
21+
22+
let totalCount = bytes.count
23+
guard totalCount > 0 else { return nil }
24+
25+
let displayCount = min(totalCount, maxBytes)
26+
let bytesArray = [UInt8](bytes.prefix(displayCount))
27+
28+
var lines: [String] = []
29+
lines.reserveCapacity(displayCount / 16 + 2)
30+
31+
let bytesPerLine = 16
32+
var offset = 0
33+
34+
while offset < displayCount {
35+
let lineEnd = min(offset + bytesPerLine, displayCount)
36+
let lineBytes = bytesArray[offset..<lineEnd]
37+
38+
// Offset column (8-digit hex)
39+
var line = String(format: "%08X ", offset)
40+
41+
// Hex columns: two groups of 8 bytes
42+
for i in 0..<bytesPerLine {
43+
if i == 8 { line += " " }
44+
if offset + i < lineEnd {
45+
line += String(format: "%02X ", lineBytes[offset + i])
46+
} else {
47+
line += " "
48+
}
49+
}
50+
51+
// ASCII column
52+
line += " |"
53+
for byte in lineBytes {
54+
if byte >= 0x20, byte <= 0x7E {
55+
line += String(UnicodeScalar(byte))
56+
} else {
57+
line += "."
58+
}
59+
}
60+
line += "|"
61+
62+
lines.append(line)
63+
offset += bytesPerLine
64+
}
65+
66+
if totalCount > maxBytes {
67+
let formatter = NumberFormatter()
68+
formatter.numberStyle = .decimal
69+
let formattedTotal = formatter.string(from: NSNumber(value: totalCount)) ?? "\(totalCount)"
70+
lines.append("... (truncated, \(formattedTotal) bytes total)")
71+
}
72+
73+
return lines.joined(separator: "\n")
74+
}
75+
76+
/// Returns a space-separated hex representation suitable for editing.
77+
///
78+
/// Format: `48 65 6C 6C 6F` — one hex byte pair separated by spaces, no offset or ASCII columns.
79+
/// - Parameter maxBytes: Maximum bytes to display before truncating (default 10KB).
80+
func formattedAsEditableHex(maxBytes: Int = 10_240) -> String? {
81+
guard let bytes = data(using: .isoLatin1) ?? data(using: .utf8) else {
82+
return nil
83+
}
84+
85+
let totalCount = bytes.count
86+
guard totalCount > 0 else { return nil }
87+
88+
let displayCount = min(totalCount, maxBytes)
89+
let bytesArray = [UInt8](bytes.prefix(displayCount))
90+
91+
var hex = bytesArray.map { String(format: "%02X", $0) }.joined(separator: " ")
92+
93+
if totalCount > maxBytes {
94+
hex += ""
95+
}
96+
97+
return hex
98+
}
99+
100+
/// Returns a compact single-line hex representation for data grid cells.
101+
///
102+
/// Format: `0x48656C6C6F` for short values, truncated with `…` for longer ones.
103+
/// - Parameter maxBytes: Maximum bytes to show before truncating (default 64).
104+
func formattedAsCompactHex(maxBytes: Int = 64) -> String? {
105+
guard let bytes = data(using: .isoLatin1) ?? data(using: .utf8) else {
106+
return nil
107+
}
108+
109+
let totalCount = bytes.count
110+
guard totalCount > 0 else { return nil }
111+
112+
let displayCount = min(totalCount, maxBytes)
113+
let bytesArray = [UInt8](bytes.prefix(displayCount))
114+
115+
var hex = "0x"
116+
for byte in bytesArray {
117+
hex += String(format: "%02X", byte)
118+
}
119+
120+
if totalCount > maxBytes {
121+
hex += ""
122+
}
123+
124+
return hex
125+
}
126+
}

TablePro/Resources/Localizable.xcstrings

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -655,6 +655,9 @@
655655
}
656656
}
657657
}
658+
},
659+
"%lld bytes" : {
660+
658661
},
659662
"%lld in · %lld out" : {
660663
"localizations" : {
@@ -4665,6 +4668,9 @@
46654668
}
46664669
}
46674670
}
4671+
},
4672+
"Copy as Hex" : {
4673+
46684674
},
46694675
"Copy as JSON" : {
46704676

@@ -8760,6 +8766,9 @@
87608766
}
87618767
}
87628768
}
8769+
},
8770+
"Hex bytes" : {
8771+
87638772
},
87648773
"Hide All" : {
87658774
"localizations" : {

TablePro/Views/Results/DataGridCellFactory.swift

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -329,9 +329,13 @@ final class DataGridCellFactory {
329329
} else {
330330
var displayValue = value ?? ""
331331

332-
if let columnType = columnType, columnType.isDateType, !displayValue.isEmpty {
333-
if let formattedDate = DateFormattingService.shared.format(dateString: displayValue) {
334-
displayValue = formattedDate
332+
if let columnType = columnType, !displayValue.isEmpty {
333+
if columnType.isDateType {
334+
if let formattedDate = DateFormattingService.shared.format(dateString: displayValue) {
335+
displayValue = formattedDate
336+
}
337+
} else if BlobFormattingService.shared.requiresFormatting(columnType: columnType) {
338+
displayValue = BlobFormattingService.shared.formatIfNeeded(displayValue, columnType: columnType, for: .grid)
335339
}
336340
}
337341

TablePro/Views/Results/DataGridView+RowActions.swift

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,12 @@ extension TableViewCoordinator {
3333

3434
func copyRows(at indices: Set<Int>) {
3535
let sortedIndices = indices.sorted()
36+
let columnTypes = (rowProvider as? InMemoryRowProvider)?.columnTypes
3637
var lines: [String] = []
3738

3839
for index in sortedIndices {
3940
guard let values = rowProvider.rowValues(at: index) else { continue }
40-
let line = values.map { $0 ?? "NULL" }.joined(separator: "\t")
41+
let line = formatRowForCopy(values: values, columnTypes: columnTypes)
4142
lines.append(line)
4243
}
4344

@@ -47,14 +48,15 @@ extension TableViewCoordinator {
4748

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

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

5557
for index in sortedIndices {
5658
guard let values = rowProvider.rowValues(at: index) else { continue }
57-
let line = values.map { $0 ?? "NULL" }.joined(separator: "\t")
59+
let line = formatRowForCopy(values: values, columnTypes: columnTypes)
5860
lines.append(line)
5961
}
6062

@@ -100,7 +102,10 @@ extension TableViewCoordinator {
100102
guard columnIndex >= 0 && columnIndex < rowProvider.columns.count else { return }
101103

102104
let value = rowProvider.value(atRow: rowIndex, column: columnIndex) ?? "NULL"
103-
ClipboardService.shared.writeText(value)
105+
let columnTypes = (rowProvider as? InMemoryRowProvider)?.columnTypes
106+
let columnType = columnTypes.flatMap { $0.indices.contains(columnIndex) ? $0[columnIndex] : nil }
107+
let copyValue = BlobFormattingService.shared.formatIfNeeded(value, columnType: columnType, for: .copy)
108+
ClipboardService.shared.writeText(copyValue)
104109
}
105110

106111
func copyRowsAsInsert(at indices: Set<Int>) {
@@ -144,6 +149,14 @@ extension TableViewCoordinator {
144149
ClipboardService.shared.writeText(converter.generateJson(rows: rows))
145150
}
146151

152+
private func formatRowForCopy(values: [String?], columnTypes: [ColumnType]?) -> String {
153+
values.enumerated().map { index, value in
154+
guard let value else { return "NULL" }
155+
let columnType = columnTypes.flatMap { $0.indices.contains(index) ? $0[index] : nil }
156+
return BlobFormattingService.shared.formatIfNeeded(value, columnType: columnType, for: .copy)
157+
}.joined(separator: "\t")
158+
}
159+
147160
private func resolveDriver() -> (any DatabaseDriver)? {
148161
guard let connectionId else { return nil }
149162
return DatabaseManager.shared.driver(for: connectionId)

TablePro/Views/Results/KeyHandlingTableView.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,11 @@ final class KeyHandlingTableView: NSTableView {
127127

128128
/// Copy selected rows to clipboard
129129
@objc func copy(_ sender: Any?) {
130+
let indices = Set(selectedRowIndexes)
130131
if let callback = coordinator?.onCopyRows {
131-
callback(Set(selectedRowIndexes))
132+
callback(indices)
133+
} else {
134+
coordinator?.copyRows(at: indices)
132135
}
133136
}
134137

0 commit comments

Comments
 (0)