Skip to content

Commit a01f673

Browse files
authored
feat: add hex editor popover for BLOB columns (#431)
* feat: add hex editor popover for BLOB columns on double-click * fix: address review — debounce validation, handle truncated BLOBs, reset stale byte count * fix: block inline editing for BLOB columns in shouldEdit guard * fix: add isBlobType to shouldEdit guard, document hex editor in data-grid docs
1 parent 5837cb5 commit a01f673

7 files changed

Lines changed: 602 additions & 2 deletions

File tree

TablePro/Views/Results/Extensions/DataGridView+Click.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,13 @@ extension TableViewCoordinator {
104104
return
105105
}
106106

107+
// BLOB columns use hex editor popover
108+
if columnIndex < rowProvider.columnTypes.count,
109+
rowProvider.columnTypes[columnIndex].isBlobType {
110+
showBlobEditorPopover(tableView: sender, row: row, column: column, columnIndex: columnIndex)
111+
return
112+
}
113+
107114
// Multiline values use the overlay editor instead of inline field editor
108115
if let value = rowProvider.value(atRow: row, column: columnIndex),
109116
value.containsLineBreak {

TablePro/Views/Results/Extensions/DataGridView+Editing.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ extension TableViewCoordinator {
3434
}
3535
if columnIndex < rowProvider.columnTypes.count {
3636
let ct = rowProvider.columnTypes[columnIndex]
37-
if ct.isDateType || ct.isJsonType || ct.isEnumType || ct.isSetType { return false }
37+
if ct.isDateType || ct.isJsonType || ct.isEnumType || ct.isSetType || ct.isBlobType { return false }
3838
}
3939
if let dropdownCols = dropdownColumns, dropdownCols.contains(columnIndex) {
4040
return false

TablePro/Views/Results/Extensions/DataGridView+Popovers.swift

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,33 @@ extension TableViewCoordinator {
101101
}
102102
}
103103

104+
func showBlobEditorPopover(tableView: NSTableView, row: Int, column: Int, columnIndex: Int) {
105+
let currentValue = rowProvider.value(atRow: row, column: columnIndex)
106+
107+
guard tableView.view(atColumn: column, row: row, makeIfNecessary: false) != nil else { return }
108+
109+
let cellRect = tableView.rect(ofRow: row).intersection(tableView.rect(ofColumn: column))
110+
PopoverPresenter.show(
111+
relativeTo: cellRect,
112+
of: tableView,
113+
contentSize: NSSize(width: 520, height: 400)
114+
) { [weak self] dismiss in
115+
HexEditorContentView(
116+
initialValue: currentValue,
117+
onCommit: { newValue in
118+
self?.commitPopoverEdit(
119+
tableView: tableView,
120+
row: row,
121+
column: column,
122+
columnIndex: columnIndex,
123+
newValue: newValue
124+
)
125+
},
126+
onDismiss: dismiss
127+
)
128+
}
129+
}
130+
104131
func showEnumPopover(tableView: NSTableView, row: Int, column: Int, columnIndex: Int) {
105132
guard tableView.view(atColumn: column, row: row, makeIfNecessary: false) != nil else { return }
106133
let columnName = rowProvider.columns[columnIndex]
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
//
2+
// HexEditorContentView.swift
3+
// TablePro
4+
//
5+
// SwiftUI popover content for viewing and editing BLOB column values as hex.
6+
//
7+
8+
import AppKit
9+
import SwiftUI
10+
11+
struct HexEditorContentView: View {
12+
let initialValue: String?
13+
let onCommit: (String) -> Void
14+
let onDismiss: () -> Void
15+
16+
@State private var hexDumpText: String
17+
@State private var editableHex: String
18+
@State private var isValid: Bool = true
19+
@State private var isTruncated: Bool = false
20+
@State private var byteCount: Int = 0
21+
@State private var validateWorkItem: DispatchWorkItem?
22+
23+
init(
24+
initialValue: String?,
25+
onCommit: @escaping (String) -> Void,
26+
onDismiss: @escaping () -> Void
27+
) {
28+
self.initialValue = initialValue
29+
self.onCommit = onCommit
30+
self.onDismiss = onDismiss
31+
32+
let service = BlobFormattingService.shared
33+
if let value = initialValue, !value.isEmpty {
34+
let editHex = service.format(value, for: .edit) ?? ""
35+
let truncated = editHex.hasSuffix("")
36+
self._hexDumpText = State(initialValue: service.format(value, for: .detail) ?? "")
37+
self._editableHex = State(initialValue: editHex)
38+
self._byteCount = State(initialValue: value.data(using: .isoLatin1)?.count ?? 0)
39+
self._isTruncated = State(initialValue: truncated)
40+
self._isValid = State(initialValue: !truncated)
41+
} else {
42+
self._hexDumpText = State(initialValue: "")
43+
self._editableHex = State(initialValue: "")
44+
self._byteCount = State(initialValue: 0)
45+
}
46+
}
47+
48+
var body: some View {
49+
VStack(spacing: 0) {
50+
HexDumpDisplayView(text: hexDumpText)
51+
52+
Divider()
53+
54+
VStack(spacing: 4) {
55+
Text("Editable Hex")
56+
.font(.caption)
57+
.foregroundStyle(.secondary)
58+
.frame(maxWidth: .infinity, alignment: .leading)
59+
60+
HexInputTextView(text: $editableHex)
61+
.frame(height: 80)
62+
63+
HStack(spacing: 4) {
64+
Text("\(byteCount) bytes")
65+
.font(.caption)
66+
.foregroundStyle(.tertiary)
67+
68+
if isTruncated {
69+
Text(String(localized: "Truncated — read only"))
70+
.font(.caption)
71+
.foregroundStyle(.orange)
72+
} else if !isValid, !editableHex.isEmpty {
73+
Text(String(localized: "Invalid hex"))
74+
.font(.caption)
75+
.foregroundStyle(.red)
76+
}
77+
78+
Spacer()
79+
}
80+
}
81+
.padding(.horizontal, 12)
82+
.padding(.vertical, 8)
83+
84+
Divider()
85+
86+
HStack {
87+
Spacer()
88+
Button("Cancel") { onDismiss() }
89+
.keyboardShortcut(.cancelAction)
90+
Button("Save") { saveHex() }
91+
.keyboardShortcut(.defaultAction)
92+
.disabled(!isValid || isTruncated)
93+
}
94+
.padding(.horizontal, 12)
95+
.padding(.vertical, 8)
96+
}
97+
.frame(width: 520, height: 400)
98+
.onChange(of: editableHex) { _, newValue in
99+
scheduleValidation(newValue)
100+
}
101+
}
102+
103+
// MARK: - Actions
104+
105+
private func saveHex() {
106+
guard isValid else { return }
107+
108+
if editableHex.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
109+
if initialValue != nil, initialValue != "" {
110+
onCommit("")
111+
}
112+
onDismiss()
113+
return
114+
}
115+
116+
guard let rawValue = BlobFormattingService.shared.parseHex(editableHex) else { return }
117+
if rawValue != initialValue {
118+
onCommit(rawValue)
119+
}
120+
onDismiss()
121+
}
122+
123+
private func scheduleValidation(_ hex: String) {
124+
validateWorkItem?.cancel()
125+
let workItem = DispatchWorkItem { [hex] in
126+
validateHex(hex)
127+
}
128+
validateWorkItem = workItem
129+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: workItem)
130+
}
131+
132+
private func validateHex(_ hex: String) {
133+
if hex.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
134+
isValid = true
135+
isTruncated = false
136+
byteCount = 0
137+
hexDumpText = ""
138+
return
139+
}
140+
141+
if hex.hasSuffix("") {
142+
isTruncated = true
143+
isValid = false
144+
return
145+
}
146+
147+
isTruncated = false
148+
if let parsed = BlobFormattingService.shared.parseHex(hex) {
149+
isValid = true
150+
byteCount = parsed.data(using: .isoLatin1)?.count ?? 0
151+
hexDumpText = parsed.formattedAsHexDump() ?? ""
152+
} else {
153+
isValid = false
154+
byteCount = 0
155+
}
156+
}
157+
}
158+
159+
// MARK: - Hex Dump Display View (Read-Only)
160+
161+
private struct HexDumpDisplayView: NSViewRepresentable {
162+
let text: String
163+
164+
func makeNSView(context: Context) -> NSScrollView {
165+
let scrollView = NSTextView.scrollableTextView()
166+
guard let textView = scrollView.documentView as? NSTextView else {
167+
return scrollView
168+
}
169+
170+
textView.isEditable = false
171+
textView.isSelectable = true
172+
textView.font = NSFont.monospacedSystemFont(
173+
ofSize: ThemeEngine.shared.activeTheme.typography.small,
174+
weight: .regular
175+
)
176+
textView.textContainerInset = NSSize(width: 8, height: 8)
177+
textView.backgroundColor = NSColor.textBackgroundColor
178+
textView.textColor = NSColor.secondaryLabelColor
179+
textView.string = text
180+
181+
return scrollView
182+
}
183+
184+
func updateNSView(_ scrollView: NSScrollView, context: Context) {
185+
guard let textView = scrollView.documentView as? NSTextView else { return }
186+
if textView.string != text {
187+
textView.string = text
188+
}
189+
}
190+
}
191+
192+
// MARK: - Hex Input Text View (Editable)
193+
194+
private struct HexInputTextView: NSViewRepresentable {
195+
@Binding var text: String
196+
197+
func makeCoordinator() -> Coordinator {
198+
Coordinator(self)
199+
}
200+
201+
func makeNSView(context: Context) -> NSScrollView {
202+
let scrollView = NSTextView.scrollableTextView()
203+
guard let textView = scrollView.documentView as? NSTextView else {
204+
return scrollView
205+
}
206+
207+
textView.isEditable = true
208+
textView.isSelectable = true
209+
textView.font = NSFont.monospacedSystemFont(
210+
ofSize: ThemeEngine.shared.activeTheme.typography.medium,
211+
weight: .regular
212+
)
213+
textView.textContainerInset = NSSize(width: 8, height: 8)
214+
textView.backgroundColor = NSColor.textBackgroundColor
215+
textView.textColor = NSColor.labelColor
216+
textView.isAutomaticQuoteSubstitutionEnabled = false
217+
textView.isAutomaticDashSubstitutionEnabled = false
218+
textView.isAutomaticTextReplacementEnabled = false
219+
textView.isAutomaticSpellingCorrectionEnabled = false
220+
textView.isGrammarCheckingEnabled = false
221+
textView.allowsUndo = true
222+
223+
textView.textContainer?.widthTracksTextView = true
224+
textView.isHorizontallyResizable = false
225+
226+
textView.delegate = context.coordinator
227+
textView.string = text
228+
229+
return scrollView
230+
}
231+
232+
func updateNSView(_ scrollView: NSScrollView, context: Context) {
233+
guard let textView = scrollView.documentView as? NSTextView else { return }
234+
if textView.string != text, !context.coordinator.isUpdating {
235+
textView.string = text
236+
}
237+
}
238+
239+
// MARK: - Coordinator
240+
241+
final class Coordinator: NSObject, NSTextViewDelegate {
242+
var parent: HexInputTextView
243+
var isUpdating = false
244+
245+
init(_ parent: HexInputTextView) {
246+
self.parent = parent
247+
}
248+
249+
func textDidChange(_ notification: Notification) {
250+
guard let textView = notification.object as? NSTextView else { return }
251+
isUpdating = true
252+
parent.text = textView.string
253+
isUpdating = false
254+
}
255+
}
256+
}

0 commit comments

Comments
 (0)