Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
7 changes: 7 additions & 0 deletions TablePro/Views/Results/Extensions/DataGridView+Click.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,13 @@ extension TableViewCoordinator {
return
}

// BLOB columns use hex editor popover
if columnIndex < rowProvider.columnTypes.count,
rowProvider.columnTypes[columnIndex].isBlobType {
showBlobEditorPopover(tableView: sender, row: row, column: column, columnIndex: columnIndex)
return
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Multiline values use the overlay editor instead of inline field editor
if let value = rowProvider.value(atRow: row, column: columnIndex),
value.containsLineBreak {
Expand Down
27 changes: 27 additions & 0 deletions TablePro/Views/Results/Extensions/DataGridView+Popovers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,33 @@ extension TableViewCoordinator {
}
}

func showBlobEditorPopover(tableView: NSTableView, row: Int, column: Int, columnIndex: Int) {
let currentValue = rowProvider.value(atRow: row, column: columnIndex)

guard tableView.view(atColumn: column, row: row, makeIfNecessary: false) != nil else { return }

let cellRect = tableView.rect(ofRow: row).intersection(tableView.rect(ofColumn: column))
PopoverPresenter.show(
relativeTo: cellRect,
of: tableView,
contentSize: NSSize(width: 520, height: 400)
) { [weak self] dismiss in
HexEditorContentView(
initialValue: currentValue,
onCommit: { newValue in
self?.commitPopoverEdit(
tableView: tableView,
row: row,
column: column,
columnIndex: columnIndex,
newValue: newValue
)
},
onDismiss: dismiss
)
}
}

func showEnumPopover(tableView: NSTableView, row: Int, column: Int, columnIndex: Int) {
guard tableView.view(atColumn: column, row: row, makeIfNecessary: false) != nil else { return }
let columnName = rowProvider.columns[columnIndex]
Expand Down
228 changes: 228 additions & 0 deletions TablePro/Views/Results/HexEditorContentView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
//
// HexEditorContentView.swift
// TablePro
//
// SwiftUI popover content for viewing and editing BLOB column values as hex.
//

import AppKit
import SwiftUI

struct HexEditorContentView: View {
let initialValue: String?
let onCommit: (String) -> Void
let onDismiss: () -> Void

@State private var hexDumpText: String
@State private var editableHex: String
@State private var isValid: Bool = true
@State private var byteCount: Int = 0

init(
initialValue: String?,
onCommit: @escaping (String) -> Void,
onDismiss: @escaping () -> Void
) {
self.initialValue = initialValue
self.onCommit = onCommit
self.onDismiss = onDismiss

let service = BlobFormattingService.shared
if let value = initialValue, !value.isEmpty {
self._hexDumpText = State(initialValue: service.format(value, for: .detail) ?? "")
self._editableHex = State(initialValue: service.format(value, for: .edit) ?? "")
self._byteCount = State(initialValue: value.data(using: .isoLatin1)?.count ?? 0)
} else {
self._hexDumpText = State(initialValue: "")
self._editableHex = State(initialValue: "")
self._byteCount = State(initialValue: 0)
}
}

var body: some View {
VStack(spacing: 0) {
HexDumpDisplayView(text: hexDumpText)

Divider()

VStack(spacing: 4) {
Text("Editable Hex")
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)

HexInputTextView(text: $editableHex)
.frame(height: 80)

HStack(spacing: 4) {
Text("\(byteCount) bytes")
.font(.caption)
.foregroundStyle(.tertiary)

if !isValid, !editableHex.isEmpty {
Text("Invalid hex")
.font(.caption)
.foregroundStyle(.red)
}

Spacer()
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)

Divider()

HStack {
Spacer()
Button("Cancel") { onDismiss() }
.keyboardShortcut(.cancelAction)
Button("Save") { saveHex() }
.keyboardShortcut(.defaultAction)
.disabled(!isValid)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
.frame(width: 520, height: 400)
.onChange(of: editableHex) { _, newValue in
validateHex(newValue)
}
}

// MARK: - Actions

private func saveHex() {
guard isValid else { return }

if editableHex.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
if initialValue != nil, initialValue != "" {
onCommit("")
}
onDismiss()
return
}

guard let rawValue = BlobFormattingService.shared.parseHex(editableHex) else { return }
if rawValue != initialValue {
onCommit(rawValue)
}
onDismiss()
}

private func validateHex(_ hex: String) {
if hex.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
isValid = true
byteCount = 0
hexDumpText = ""
return
}

if let parsed = BlobFormattingService.shared.parseHex(hex) {
isValid = true
byteCount = parsed.data(using: .isoLatin1)?.count ?? 0
hexDumpText = parsed.formattedAsHexDump() ?? ""
} else {
isValid = false
}
}
}

// MARK: - Hex Dump Display View (Read-Only)

private struct HexDumpDisplayView: NSViewRepresentable {
let text: String

func makeNSView(context: Context) -> NSScrollView {
let scrollView = NSTextView.scrollableTextView()
guard let textView = scrollView.documentView as? NSTextView else {
return scrollView
}

textView.isEditable = false
textView.isSelectable = true
textView.font = NSFont.monospacedSystemFont(
ofSize: ThemeEngine.shared.activeTheme.typography.small,
weight: .regular
)
textView.textContainerInset = NSSize(width: 8, height: 8)
textView.backgroundColor = NSColor.textBackgroundColor
textView.textColor = NSColor.secondaryLabelColor
textView.string = text

return scrollView
}

func updateNSView(_ scrollView: NSScrollView, context: Context) {
guard let textView = scrollView.documentView as? NSTextView else { return }
if textView.string != text {
textView.string = text
}
}
}

// MARK: - Hex Input Text View (Editable)

private struct HexInputTextView: NSViewRepresentable {
@Binding var text: String

func makeCoordinator() -> Coordinator {
Coordinator(self)
}

func makeNSView(context: Context) -> NSScrollView {
let scrollView = NSTextView.scrollableTextView()
guard let textView = scrollView.documentView as? NSTextView else {
return scrollView
}

textView.isEditable = true
textView.isSelectable = true
textView.font = NSFont.monospacedSystemFont(
ofSize: ThemeEngine.shared.activeTheme.typography.medium,
weight: .regular
)
textView.textContainerInset = NSSize(width: 8, height: 8)
textView.backgroundColor = NSColor.textBackgroundColor
textView.textColor = NSColor.labelColor
textView.isAutomaticQuoteSubstitutionEnabled = false
textView.isAutomaticDashSubstitutionEnabled = false
textView.isAutomaticTextReplacementEnabled = false
textView.isAutomaticSpellingCorrectionEnabled = false
textView.isGrammarCheckingEnabled = false
textView.allowsUndo = true

textView.textContainer?.widthTracksTextView = true
textView.isHorizontallyResizable = false

textView.delegate = context.coordinator
textView.string = text

return scrollView
}

func updateNSView(_ scrollView: NSScrollView, context: Context) {
guard let textView = scrollView.documentView as? NSTextView else { return }
if textView.string != text, !context.coordinator.isUpdating {
textView.string = text
}
}

// MARK: - Coordinator

final class Coordinator: NSObject, NSTextViewDelegate {
var parent: HexInputTextView
var isUpdating = false

init(_ parent: HexInputTextView) {
self.parent = parent
}

func textDidChange(_ notification: Notification) {
guard let textView = notification.object as? NSTextView else { return }
isUpdating = true
parent.text = textView.string
isUpdating = false
}
}
}
Loading
Loading