diff --git a/CHANGELOG.md b/CHANGELOG.md index e895228f7..dee8192c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Right-click a column header to copy all its values from the loaded rows (#1325) - Copy as submenu on the row context menu now offers CSV, CSV with Headers, Markdown table, and IN Clause for SQL `WHERE id IN (...)` lookups (#1325) +- Double-click or press Return on a read-only query result cell to open a selectable text viewer in the cell. JSON columns open the JSON viewer in a popover, BLOB columns open the hex viewer. The value is selectable and copyable (#1336) ### Changed diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 890e0328a..bc1c9fd34 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -49481,6 +49481,7 @@ } }, "Truncated — read only" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -49501,6 +49502,9 @@ } } } + }, + "Truncated, read only" : { + }, "Trust" : { "localizations" : { diff --git a/TablePro/Views/Results/CellInteractionResolver.swift b/TablePro/Views/Results/CellInteractionResolver.swift new file mode 100644 index 000000000..1812195d8 --- /dev/null +++ b/TablePro/Views/Results/CellInteractionResolver.swift @@ -0,0 +1,53 @@ +// +// CellInteractionResolver.swift +// TablePro +// + +import Foundation + +internal struct CellContext: Equatable { + let columnType: ColumnType? + let value: String? + let isTableEditable: Bool + let isRowDeleted: Bool + let isImmutableColumn: Bool +} + +internal enum CellInteractionMode: Equatable { + case viewInline(value: String) + case viewJson + case viewBlob + + case editInline(value: String) + case editOverlay(value: String) + case editJson + case editBlob + + case blocked +} + +internal struct CellInteractionResolver { + func resolve(_ context: CellContext) -> CellInteractionMode { + guard !context.isRowDeleted else { return .blocked } + + let isReadOnly = !context.isTableEditable || context.isImmutableColumn + + if isReadOnly { + if let columnType = context.columnType { + if columnType.isBlobType { return .viewBlob } + if columnType.isJsonType { return .viewJson } + } + return .viewInline(value: context.value ?? "NULL") + } + + if let columnType = context.columnType { + if columnType.isBlobType { return .editBlob } + if columnType.isJsonType { return .editJson } + } + + let value = context.value ?? "" + if value.containsLineBreak { return .editOverlay(value: value) } + if value.looksLikeJson { return .editJson } + return .editInline(value: value) + } +} diff --git a/TablePro/Views/Results/CellOverlayBase.swift b/TablePro/Views/Results/CellOverlayBase.swift new file mode 100644 index 000000000..eb940e47a --- /dev/null +++ b/TablePro/Views/Results/CellOverlayBase.swift @@ -0,0 +1,197 @@ +// +// CellOverlayBase.swift +// TablePro +// + +import AppKit + +enum CellOverlayDismissReason { + case userAction + case scroll + case columnResize + case appResign + case windowResignKey + case outsideClick +} + +@MainActor +class CellOverlayBase: NSObject { + private var container: CellOverlayContainerView? + private weak var hostTableView: NSTableView? + private var scrollObserver: NSObjectProtocol? + private var columnResizeObserver: NSObjectProtocol? + private var appResignObserver: NSObjectProtocol? + private var windowResignKeyObserver: NSObjectProtocol? + private var outsideClickMonitor: Any? + + private(set) var row: Int = -1 + private(set) var column: Int = -1 + private(set) var columnIndex: Int = -1 + + var isActive: Bool { container != nil } + var containerView: NSView? { container } + var tableView: NSTableView? { hostTableView } + + func raiseToFront() { + guard let container, let hostTableView, container.superview === hostTableView else { return } + guard hostTableView.subviews.last !== container else { return } + hostTableView.addSubview(container) + } + + func install( + in tableView: NSTableView, + row: Int, + column: Int, + columnIndex: Int, + container: CellOverlayContainerView + ) { + self.hostTableView = tableView + self.row = row + self.column = column + self.columnIndex = columnIndex + tableView.addSubview(container) + self.container = container + installDismissObservers() + } + + func handleDismiss(reason: CellOverlayDismissReason) { + removeOverlay() + } + + func removeOverlay() { + guard let activeContainer = container else { return } + removeDismissObservers() + activeContainer.removeFromSuperview() + container = nil + if let hostTableView { + hostTableView.window?.makeFirstResponder(hostTableView) + } + } + + static func overlayFrame(for cellFrame: NSRect, value: String) -> NSRect { + let lineHeight = ThemeEngine.shared.dataGridFonts.regular.boundingRectForFont.height + 4 + var newlineCount = 0 + for scalar in value.unicodeScalars where scalar == "\n" { + newlineCount += 1 + } + let lineCount = CGFloat(newlineCount + 1) + let contentHeight = max(lineCount * lineHeight + 8, cellFrame.height) + let height = min(max(contentHeight, cellFrame.height), 120) + return NSRect(x: cellFrame.origin.x, y: cellFrame.origin.y, width: cellFrame.width, height: height) + } + + static func makeContainer(frame: NSRect) -> CellOverlayContainerView { + let container = CellOverlayContainerView(frame: frame) + container.wantsLayer = true + container.layer?.borderWidth = 2 + container.layer?.borderColor = NSColor.keyboardFocusIndicatorColor.cgColor + container.layer?.cornerRadius = 2 + container.layer?.masksToBounds = true + container.layer?.backgroundColor = NSColor.textBackgroundColor.cgColor + return container + } + + static func makeScrollView(in container: NSView) -> NSScrollView { + let scrollView = NSScrollView(frame: container.bounds) + scrollView.autoresizingMask = [.width, .height] + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = true + scrollView.borderType = .noBorder + scrollView.drawsBackground = true + scrollView.backgroundColor = .textBackgroundColor + return scrollView + } + + private func installDismissObservers() { + guard let hostTableView else { return } + + if let clipView = hostTableView.enclosingScrollView?.contentView { + scrollObserver = NotificationCenter.default.addObserver( + forName: NSView.boundsDidChangeNotification, + object: clipView, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.handleDismiss(reason: .scroll) + } + } + } + + columnResizeObserver = NotificationCenter.default.addObserver( + forName: NSTableView.columnDidResizeNotification, + object: hostTableView, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.handleDismiss(reason: .columnResize) + } + } + + appResignObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didResignActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.handleDismiss(reason: .appResign) + } + } + + if let overlayWindow = hostTableView.window { + windowResignKeyObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didResignKeyNotification, + object: overlayWindow, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.handleDismiss(reason: .windowResignKey) + } + } + } + + outsideClickMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] event in + MainActor.assumeIsolated { + self?.handleOutsideClick(event: event) + } + return event + } + } + + private func removeDismissObservers() { + if let observer = scrollObserver { + NotificationCenter.default.removeObserver(observer) + scrollObserver = nil + } + if let observer = columnResizeObserver { + NotificationCenter.default.removeObserver(observer) + columnResizeObserver = nil + } + if let observer = appResignObserver { + NotificationCenter.default.removeObserver(observer) + appResignObserver = nil + } + if let observer = windowResignKeyObserver { + NotificationCenter.default.removeObserver(observer) + windowResignKeyObserver = nil + } + if let monitor = outsideClickMonitor { + NSEvent.removeMonitor(monitor) + outsideClickMonitor = nil + } + } + + private func handleOutsideClick(event: NSEvent) { + guard let containerView = container, + let containerWindow = containerView.window, + event.window === containerWindow else { return } + let frameInWindow = containerView.convert(containerView.bounds, to: nil) + if !frameInWindow.contains(event.locationInWindow) { + handleDismiss(reason: .outsideClick) + } + } +} + +final class CellOverlayContainerView: NSView { + override var isFlipped: Bool { true } +} diff --git a/TablePro/Views/Results/CellOverlayEditor.swift b/TablePro/Views/Results/CellOverlayEditor.swift index 7c5db03fb..9fcd3fd2b 100644 --- a/TablePro/Views/Results/CellOverlayEditor.swift +++ b/TablePro/Views/Results/CellOverlayEditor.swift @@ -6,36 +6,13 @@ import AppKit @MainActor -final class CellOverlayEditor: NSObject, NSTextViewDelegate { - private var container: OverlayContainerView? - private var textView: OverlayTextView? - private weak var tableView: NSTableView? - private var scrollObserver: NSObjectProtocol? - private var columnResizeObserver: NSObjectProtocol? - private var appResignObserver: NSObjectProtocol? - private var windowResignKeyObserver: NSObjectProtocol? - private var outsideClickMonitor: Any? - - private(set) var row: Int = -1 - private(set) var column: Int = -1 - private(set) var columnIndex: Int = -1 +final class CellOverlayEditor: CellOverlayBase, NSTextViewDelegate { + private var editorTextView: OverlayTextView? private var initialValue: String = "" var onCommit: ((_ row: Int, _ columnIndex: Int, _ newValue: String) -> Void)? var onTabNavigation: ((_ row: Int, _ column: Int, _ forward: Bool) -> Void)? - var isActive: Bool { container != nil } - - var containerView: NSView? { container } - - func raiseToFront() { - guard let container, let tableView, container.superview === tableView else { return } - guard tableView.subviews.last !== container else { return } - tableView.addSubview(container) - } - - // MARK: - Show / Dismiss - func show( in tableView: NSTableView, row: Int, @@ -45,193 +22,63 @@ final class CellOverlayEditor: NSObject, NSTextViewDelegate { ) { dismiss(commit: true) - self.tableView = tableView - self.row = row - self.column = column - self.columnIndex = columnIndex - self.initialValue = value - let cellFrame = tableView.frameOfCell(atColumn: column, row: row) guard !cellFrame.isEmpty else { return } guard let window = tableView.window else { return } - let lineHeight = ThemeEngine.shared.dataGridFonts.regular.boundingRectForFont.height + 4 - var newlineCount = 0 - for scalar in value.unicodeScalars where scalar == "\n" { - newlineCount += 1 - } - let lineCount = CGFloat(newlineCount + 1) - let contentHeight = max(lineCount * lineHeight + 8, cellFrame.height) - let overlayHeight = min(max(contentHeight, cellFrame.height), 120) - - let editorFrame = NSRect( - x: cellFrame.origin.x, - y: cellFrame.origin.y, - width: cellFrame.width, - height: overlayHeight - ) - - let containerView = OverlayContainerView(frame: editorFrame) - containerView.wantsLayer = true - containerView.layer?.borderWidth = 2 - containerView.layer?.borderColor = NSColor.keyboardFocusIndicatorColor.cgColor - containerView.layer?.cornerRadius = 2 - containerView.layer?.masksToBounds = true - containerView.layer?.backgroundColor = NSColor.textBackgroundColor.cgColor - - let scrollView = NSScrollView(frame: containerView.bounds) - scrollView.autoresizingMask = [.width, .height] - scrollView.hasVerticalScroller = true - scrollView.hasHorizontalScroller = false - scrollView.autohidesScrollers = true - scrollView.borderType = .noBorder - scrollView.drawsBackground = true - scrollView.backgroundColor = .textBackgroundColor - - let editorTextView = OverlayTextView(frame: scrollView.bounds) - editorTextView.overlayEditor = self - editorTextView.isRichText = false - editorTextView.allowsUndo = true - editorTextView.font = ThemeEngine.shared.dataGridFonts.regular - editorTextView.textColor = .labelColor - editorTextView.backgroundColor = .textBackgroundColor - editorTextView.isVerticallyResizable = true - editorTextView.isHorizontallyResizable = false - editorTextView.textContainer?.widthTracksTextView = true - editorTextView.textContainer?.containerSize = NSSize( + let frame = Self.overlayFrame(for: cellFrame, value: value) + let containerView = Self.makeContainer(frame: frame) + let scrollView = Self.makeScrollView(in: containerView) + + let textView = OverlayTextView(frame: scrollView.bounds) + textView.overlayEditor = self + textView.isEditable = true + textView.isRichText = false + textView.allowsUndo = true + textView.font = ThemeEngine.shared.dataGridFonts.regular + textView.textColor = .labelColor + textView.backgroundColor = .textBackgroundColor + textView.isVerticallyResizable = true + textView.isHorizontallyResizable = false + textView.textContainer?.widthTracksTextView = true + textView.textContainer?.containerSize = NSSize( width: scrollView.bounds.width, height: CGFloat.greatestFiniteMagnitude ) - editorTextView.delegate = self - editorTextView.string = value - editorTextView.selectAll(nil) + textView.delegate = self + textView.string = value + textView.selectAll(nil) - scrollView.documentView = editorTextView + scrollView.documentView = textView containerView.addSubview(scrollView) - tableView.addSubview(containerView) - container = containerView - textView = editorTextView + initialValue = value + editorTextView = textView - window.makeFirstResponder(editorTextView) + install(in: tableView, row: row, column: column, columnIndex: columnIndex, container: containerView) + window.makeFirstResponder(textView) + } - installDismissObservers() + override func handleDismiss(reason: CellOverlayDismissReason) { + dismiss(commit: reason != .columnResize) } func dismiss(commit: Bool) { - guard let activeContainer = container, let activeTextView = textView else { return } - + guard let activeTextView = editorTextView else { return } let newValue = activeTextView.string let originalValue = initialValue + let dismissRow = row + let dismissColumnIndex = columnIndex - removeDismissObservers() - - activeContainer.removeFromSuperview() - container = nil - textView = nil + editorTextView = nil initialValue = "" - - if let tableView { - tableView.window?.makeFirstResponder(tableView) - } + removeOverlay() if commit, newValue != originalValue { - onCommit?(row, columnIndex, newValue) - } - } - - // MARK: - Observers - - private func installDismissObservers() { - guard let tableView else { return } - - if let clipView = tableView.enclosingScrollView?.contentView { - scrollObserver = NotificationCenter.default.addObserver( - forName: NSView.boundsDidChangeNotification, - object: clipView, - queue: .main - ) { [weak self] _ in - MainActor.assumeIsolated { - self?.dismiss(commit: true) - } - } - } - - columnResizeObserver = NotificationCenter.default.addObserver( - forName: NSTableView.columnDidResizeNotification, - object: tableView, - queue: .main - ) { [weak self] _ in - MainActor.assumeIsolated { - self?.dismiss(commit: false) - } - } - - appResignObserver = NotificationCenter.default.addObserver( - forName: NSApplication.didResignActiveNotification, - object: nil, - queue: .main - ) { [weak self] _ in - MainActor.assumeIsolated { - self?.dismiss(commit: true) - } - } - - if let editorWindow = tableView.window { - windowResignKeyObserver = NotificationCenter.default.addObserver( - forName: NSWindow.didResignKeyNotification, - object: editorWindow, - queue: .main - ) { [weak self] _ in - MainActor.assumeIsolated { - self?.dismiss(commit: true) - } - } - } - - outsideClickMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] event in - MainActor.assumeIsolated { - self?.handleOutsideClick(event: event) - } - return event - } - } - - private func removeDismissObservers() { - if let observer = scrollObserver { - NotificationCenter.default.removeObserver(observer) - scrollObserver = nil - } - if let observer = columnResizeObserver { - NotificationCenter.default.removeObserver(observer) - columnResizeObserver = nil - } - if let observer = appResignObserver { - NotificationCenter.default.removeObserver(observer) - appResignObserver = nil - } - if let observer = windowResignKeyObserver { - NotificationCenter.default.removeObserver(observer) - windowResignKeyObserver = nil - } - if let monitor = outsideClickMonitor { - NSEvent.removeMonitor(monitor) - outsideClickMonitor = nil + onCommit?(dismissRow, dismissColumnIndex, newValue) } } - private func handleOutsideClick(event: NSEvent) { - guard let containerView = container, - let containerWindow = containerView.window, - event.window === containerWindow else { return } - let frameInWindow = containerView.convert(containerView.bounds, to: nil) - if !frameInWindow.contains(event.locationInWindow) { - dismiss(commit: true) - } - } - - // MARK: - NSTextViewDelegate - func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { if commandSelector == #selector(NSResponder.insertNewline(_:)) { if NSApp.currentEvent?.modifierFlags.contains(.option) == true { @@ -248,16 +95,16 @@ final class CellOverlayEditor: NSObject, NSTextViewDelegate { } if commandSelector == #selector(NSResponder.insertTab(_:)) { - let r = row, c = column + let dismissRow = row, dismissColumn = column dismiss(commit: true) - onTabNavigation?(r, c, true) + onTabNavigation?(dismissRow, dismissColumn, true) return true } if commandSelector == #selector(NSResponder.insertBacktab(_:)) { - let r = row, c = column + let dismissRow = row, dismissColumn = column dismiss(commit: true) - onTabNavigation?(r, c, false) + onTabNavigation?(dismissRow, dismissColumn, false) return true } @@ -265,14 +112,6 @@ final class CellOverlayEditor: NSObject, NSTextViewDelegate { } } -// MARK: - Container View - -private final class OverlayContainerView: NSView { - override var isFlipped: Bool { true } -} - -// MARK: - Overlay Text View - private final class OverlayTextView: NSTextView { private let storedUndoManager = UndoManager() diff --git a/TablePro/Views/Results/CellOverlayViewer.swift b/TablePro/Views/Results/CellOverlayViewer.swift new file mode 100644 index 000000000..05f9c26a0 --- /dev/null +++ b/TablePro/Views/Results/CellOverlayViewer.swift @@ -0,0 +1,68 @@ +// +// CellOverlayViewer.swift +// TablePro +// + +import AppKit + +@MainActor +final class CellOverlayViewer: CellOverlayBase, NSTextViewDelegate { + func show( + in tableView: NSTableView, + row: Int, + column: Int, + columnIndex: Int, + value: String + ) { + removeOverlay() + + let cellFrame = tableView.frameOfCell(atColumn: column, row: row) + guard !cellFrame.isEmpty else { return } + guard let window = tableView.window else { return } + + let frame = Self.overlayFrame(for: cellFrame, value: value) + let containerView = Self.makeContainer(frame: frame) + let scrollView = Self.makeScrollView(in: containerView) + + let textView = NSTextView(frame: scrollView.bounds) + textView.isEditable = false + textView.isSelectable = true + textView.isRichText = false + textView.font = ThemeEngine.shared.dataGridFonts.regular + textView.textColor = .labelColor + textView.backgroundColor = .textBackgroundColor + textView.isVerticallyResizable = true + textView.isHorizontallyResizable = false + textView.textContainer?.widthTracksTextView = true + textView.textContainer?.containerSize = NSSize( + width: scrollView.bounds.width, + height: CGFloat.greatestFiniteMagnitude + ) + textView.delegate = self + textView.string = value + textView.selectAll(nil) + + scrollView.documentView = textView + containerView.addSubview(scrollView) + + install(in: tableView, row: row, column: column, columnIndex: columnIndex, container: containerView) + window.makeFirstResponder(textView) + } + + func dismiss() { + removeOverlay() + } + + func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + switch commandSelector { + case #selector(NSResponder.insertNewline(_:)), + #selector(NSResponder.cancelOperation(_:)), + #selector(NSResponder.insertTab(_:)), + #selector(NSResponder.insertBacktab(_:)): + removeOverlay() + return true + default: + return false + } + } +} diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 3080619b2..5aa49651a 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -95,6 +95,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData let columnPool = DataGridColumnPool() let tableRowsController = TableRowsController() var overlayEditor: CellOverlayEditor? + var overlayViewer: CellOverlayViewer? var settingsCancellable: AnyCancellable? var themeCancellable: AnyCancellable? @@ -196,6 +197,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData prewarmResumeTask = nil detachScrollObservers() overlayEditor?.dismiss(commit: false) + overlayViewer?.dismiss() settingsCancellable?.cancel() settingsCancellable = nil themeCancellable?.cancel() @@ -492,17 +494,20 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData case .rowsInserted(let indices): guard !indices.isEmpty else { return } overlayEditor?.dismiss(commit: false) + overlayViewer?.dismiss() dismissFKPreviewOnColumnChange() appendInsertedIDsToSortedIDs(at: indices) applyInsertedRows(indices) case .rowsRemoved(let indices): guard !indices.isEmpty else { return } overlayEditor?.dismiss(commit: false) + overlayViewer?.dismiss() dismissFKPreviewOnColumnChange() removeMissingIDsFromSortedIDs() applyRemovedRows(indices) case .columnsReplaced, .fullReplace: overlayEditor?.dismiss(commit: false) + overlayViewer?.dismiss() dismissFKPreviewOnColumnChange() sortedIDs = nil applyFullReplace() @@ -581,6 +586,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData func commitActiveCellEdit() { overlayEditor?.dismiss(commit: true) + overlayViewer?.dismiss() guard let tableView, let window = tableView.window else { return } if let firstResponder = window.firstResponder as? NSView, firstResponder.isDescendant(of: tableView) { diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 0665159be..302bc7fd9 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -142,6 +142,7 @@ struct DataGridView: NSViewRepresentable { if tableView.editedRow >= 0 { return } if let editor = coordinator.overlayEditor, editor.isActive { return } + if let viewer = coordinator.overlayViewer, viewer.isActive { return } coordinator.tableRowsProvider = tableRowsProvider coordinator.tableRowsMutator = tableRowsMutator diff --git a/TablePro/Views/Results/Extensions/DataGridView+Click.swift b/TablePro/Views/Results/Extensions/DataGridView+Click.swift index 8323045fd..d1f560372 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Click.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Click.swift @@ -10,57 +10,52 @@ extension TableViewCoordinator { // MARK: - Click Handlers @objc func handleDoubleClick(_ sender: NSTableView) { - guard isEditable else { return } - let row = sender.clickedRow let column = sender.clickedColumn guard row >= 0, column > 0 else { return } - guard let columnIndex = DataGridView.dataColumnIndex(for: column, in: sender, schema: identitySchema) else { return } - guard !changeManager.isRowDeleted(row) else { return } + handleCellInteraction(row: row, tableColumn: column, columnIndex: columnIndex, tableView: sender) + } - let tableRows = tableRowsProvider() - let immutable = databaseType.map { PluginManager.shared.immutableColumns(for: $0) } ?? [] - if !immutable.isEmpty, - columnIndex < tableRows.columns.count, - immutable.contains(tableRows.columns[columnIndex]) { - return - } + func handleCellInteraction(row: Int, tableColumn: Int, columnIndex: Int, tableView: NSTableView) { + guard let context = makeCellContext(row: row, columnIndex: columnIndex) else { return } + guard tableView.view(atColumn: tableColumn, row: row, makeIfNecessary: false) != nil else { return } - if columnIndex < tableRows.columns.count { - let columnName = tableRows.columns[columnIndex] - if let fkInfo = tableRows.columnForeignKeys[columnName] { - showForeignKeyPopover(tableView: sender, row: row, column: column, columnIndex: columnIndex, fkInfo: fkInfo) - return - } + switch CellInteractionResolver().resolve(context) { + case .blocked: + return + case .viewInline(let value): + showOverlayViewer(tableView: tableView, row: row, column: tableColumn, columnIndex: columnIndex, value: value) + case .viewJson: + showJSONViewerPopover(tableView: tableView, row: row, column: tableColumn, columnIndex: columnIndex) + case .viewBlob: + showBlobViewerPopover(tableView: tableView, row: row, column: tableColumn, columnIndex: columnIndex) + case .editInline: + beginCellEdit(row: row, tableColumnIndex: tableColumn) + case .editOverlay(let value): + showOverlayEditor(tableView: tableView, row: row, column: tableColumn, columnIndex: columnIndex, value: value) + case .editJson: + showJSONEditorPopover(tableView: tableView, row: row, column: tableColumn, columnIndex: columnIndex) + case .editBlob: + showBlobEditorPopover(tableView: tableView, row: row, column: tableColumn, columnIndex: columnIndex) } + } - // Column-type guards run BEFORE content checks. Binary cells contain bytes - // that may incidentally match line-break or JSON heuristics; routing them - // through the text/overlay editor would corrupt the bytes on save. - if columnIndex < tableRows.columnTypes.count { - let ct = tableRows.columnTypes[columnIndex] - if ct.isBlobType { - showBlobEditorPopover(tableView: sender, row: row, column: column, columnIndex: columnIndex) - return - } - if ct.isJsonType { - showJSONEditorPopover(tableView: sender, row: row, column: column, columnIndex: columnIndex) - return - } - } + private func makeCellContext(row: Int, columnIndex: Int) -> CellContext? { + let tableRows = tableRowsProvider() + guard row >= 0, columnIndex >= 0, columnIndex < tableRows.columns.count else { return nil } - let value = cellValue(at: row, column: columnIndex) - if let value, value.containsLineBreak { - showOverlayEditor(tableView: sender, row: row, column: column, columnIndex: columnIndex, value: value) - return - } - if let value, value.looksLikeJson { - showJSONEditorPopover(tableView: sender, row: row, column: column, columnIndex: columnIndex) - return - } + let columnName = tableRows.columns[columnIndex] + let columnType = columnIndex < tableRows.columnTypes.count ? tableRows.columnTypes[columnIndex] : nil + let immutable = databaseType.map { PluginManager.shared.immutableColumns(for: $0) } ?? [] - beginCellEdit(row: row, tableColumnIndex: column) + return CellContext( + columnType: columnType, + value: cellValue(at: row, column: columnIndex), + isTableEditable: isEditable, + isRowDeleted: changeManager.isRowDeleted(row), + isImmutableColumn: immutable.contains(columnName) + ) } // MARK: - Chevron Click @@ -89,20 +84,20 @@ extension TableViewCoordinator { guard columnIndex < tableRows.columnTypes.count, columnIndex < tableRows.columns.count else { return } - let ct = tableRows.columnTypes[columnIndex] + let columnType = tableRows.columnTypes[columnIndex] let columnName = tableRows.columns[columnIndex] - if ct.isBooleanType { + if columnType.isBooleanType { showDropdownMenu(tableView: tableView, row: row, column: column, columnIndex: columnIndex) } else if let values = tableRows.columnEnumValues[columnName], !values.isEmpty { - if ct.isSetType { + if columnType.isSetType { showSetPopover(tableView: tableView, row: row, column: column, columnIndex: columnIndex) } else { showEnumPopover(tableView: tableView, row: row, column: column, columnIndex: columnIndex) } - } else if ct.isJsonType { + } else if columnType.isJsonType { showJSONEditorPopover(tableView: tableView, row: row, column: column, columnIndex: columnIndex) - } else if ct.isBlobType { + } else if columnType.isBlobType { showBlobEditorPopover(tableView: tableView, row: row, column: column, columnIndex: columnIndex) } } diff --git a/TablePro/Views/Results/Extensions/DataGridView+Editing.swift b/TablePro/Views/Results/Extensions/DataGridView+Editing.swift index 2ea83c167..186e9c85e 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Editing.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Editing.swift @@ -22,9 +22,6 @@ extension TableViewCoordinator { let immutable = databaseType.map { PluginManager.shared.immutableColumns(for: $0) } ?? [] if immutable.contains(tableRows.columns[columnIndex]) { return .blocked } - let columnName = tableRows.columns[columnIndex] - if tableRows.columnForeignKeys[columnName] != nil { return .blocked } - if columnIndex < tableRows.columnTypes.count { let ct = tableRows.columnTypes[columnIndex] if ct.isJsonType || ct.isBlobType { @@ -84,9 +81,19 @@ extension TableViewCoordinator { editor.onTabNavigation = { [weak self] row, column, forward in self?.handleOverlayTabNavigation(row: row, column: column, forward: forward) } + overlayViewer?.dismiss() editor.show(in: tableView, row: row, column: column, columnIndex: columnIndex, value: value) } + func showOverlayViewer(tableView: NSTableView, row: Int, column: Int, columnIndex: Int, value: String) { + if overlayViewer == nil { + overlayViewer = CellOverlayViewer() + } + guard let viewer = overlayViewer else { return } + overlayEditor?.dismiss(commit: false) + viewer.show(in: tableView, row: row, column: column, columnIndex: columnIndex, value: value) + } + func handleOverlayTabNavigation(row: Int, column: Int, forward: Bool) { guard let tableView = tableView else { return } diff --git a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift index 453c30803..7f212ed11 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift @@ -24,31 +24,6 @@ extension TableViewCoordinator { return displayRow.values[columnIndex] } - func showForeignKeyPopover(tableView: NSTableView, row: Int, column: Int, columnIndex: Int, fkInfo: ForeignKeyInfo) { - let currentValue = cellValue(at: row, column: columnIndex) - - guard tableView.view(atColumn: column, row: row, makeIfNecessary: false) != nil else { return } - guard let databaseType, let connectionId else { return } - - let cellRect = tableView.rect(ofRow: row).intersection(tableView.rect(ofColumn: column)) - PopoverPresenter.show( - relativeTo: cellRect, - of: tableView, - contentSize: NSSize(width: 420, height: 320) - ) { [weak self] dismiss in - ForeignKeyPopoverContentView( - currentValue: currentValue, - fkInfo: fkInfo, - connectionId: connectionId, - databaseType: databaseType, - onCommit: { newValue in - self?.commitPopoverEdit(row: row, columnIndex: columnIndex, newValue: newValue) - }, - onDismiss: dismiss - ) - } - } - func toggleForeignKeyPreview(tableView: NSTableView, row: Int, column: Int, columnIndex: Int) { if let popover = activeFKPreviewPopover, popover.isShown { popover.close() @@ -178,13 +153,7 @@ extension TableViewCoordinator { } func showBlobEditorPopover(tableView: NSTableView, row: Int, column: Int, columnIndex: Int) { - let typed = cellTypedValue(at: row, column: columnIndex) - let currentValue: String? - switch typed { - case .null: currentValue = nil - case .text(let s): currentValue = s - case .bytes(let data): currentValue = String(data: data, encoding: .isoLatin1) - } + let currentValue = blobStringValue(at: row, columnIndex: columnIndex) guard tableView.view(atColumn: column, row: row, makeIfNecessary: false) != nil else { return } @@ -316,6 +285,64 @@ extension TableViewCoordinator { func commitBinaryEdit(row: Int, columnIndex: Int, data: Data) { commitTypedCellEdit(row: row, columnIndex: columnIndex, newValue: .bytes(data)) } + + func showJSONViewerPopover(tableView: NSTableView, row: Int, column: Int, columnIndex: Int) { + let currentValue = cellValue(at: row, column: columnIndex) + let tableRows = tableRowsProvider() + guard columnIndex >= 0, columnIndex < tableRows.columns.count else { return } + let columnName = tableRows.columns[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: nil + ) { dismiss in + JSONViewerContentView( + initialValue: currentValue, + columnName: columnName, + onDismiss: dismiss, + onPopOut: { currentText in + dismiss() + JSONViewerWindowController.open( + text: currentText, + columnName: columnName, + isEditable: false, + onCommit: nil + ) + } + ) + } + } + + func showBlobViewerPopover(tableView: NSTableView, row: Int, column: Int, columnIndex: Int) { + let currentValue = blobStringValue(at: row, columnIndex: 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: nil + ) { dismiss in + HexEditorContentView( + initialValue: currentValue, + isEditable: false, + onDismiss: dismiss + ) + } + } + + private func blobStringValue(at row: Int, columnIndex: Int) -> String? { + switch cellTypedValue(at: row, column: columnIndex) { + case .null: return nil + case .text(let text): return text + case .bytes(let data): return String(data: data, encoding: .isoLatin1) + } + } } private final class DropdownMenuContext { diff --git a/TablePro/Views/Results/ForeignKeyPopoverContentView.swift b/TablePro/Views/Results/ForeignKeyPopoverContentView.swift deleted file mode 100644 index d328c1ca1..000000000 --- a/TablePro/Views/Results/ForeignKeyPopoverContentView.swift +++ /dev/null @@ -1,191 +0,0 @@ -// -// ForeignKeyPopoverContentView.swift -// TablePro -// -// SwiftUI popover content for searchable foreign key column editing. -// - -import os -import SwiftUI -import TableProPluginKit - -struct ForeignKeyPopoverContentView: View { - let currentValue: String? - let fkInfo: ForeignKeyInfo - let connectionId: UUID - let databaseType: DatabaseType - let onCommit: (String) -> Void - let onDismiss: () -> Void - - @State private var searchText = "" - @State private var allValues: [FKValue] = [] - @State private var selectedId: String? - @State private var isLoading = true - - private static let logger = Logger(subsystem: "com.TablePro", category: "FKPopover") - private static let maxFetchRows = 1_000 - private static let rowHeight: CGFloat = 24 - private static let searchAreaHeight: CGFloat = 44 - private static let maxHeight: CGFloat = 320 - - private var filteredValues: [FKValue] { - let query = searchText.lowercased() - if query.isEmpty { return allValues } - return allValues.filter { $0.display.lowercased().contains(query) } - } - - private var listHeight: CGFloat { - let contentHeight = CGFloat(filteredValues.count) * Self.rowHeight - return min(contentHeight, Self.maxHeight - Self.searchAreaHeight) - } - - var body: some View { - VStack(spacing: 0) { - NativeSearchField(text: $searchText, placeholder: String(localized: "Search...")) - .padding(.horizontal, 8) - .padding(.vertical, 8) - - Divider() - - if isLoading { - ProgressView() - .frame(maxWidth: .infinity, alignment: .center) - .frame(height: 60) - } else if filteredValues.isEmpty { - Text("No values found") - .foregroundStyle(.secondary) - .font(.callout) - .frame(maxWidth: .infinity, alignment: .leading) - .frame(height: 60) - } else { - List(filteredValues, selection: $selectedId) { value in - Button { - onCommit(value.id) - onDismiss() - } label: { - rowLabel(for: value) - .frame(maxWidth: .infinity, alignment: .leading) - } - .buttonStyle(.plain) - .listRowInsets(EdgeInsets( - top: 2, leading: 6, bottom: 2, trailing: 6 - )) - } - .listStyle(.plain) - .environment(\.defaultMinListRowHeight, Self.rowHeight) - .frame(height: listHeight) - .onKeyPress(.return) { - guard let id = selectedId else { return .ignored } - onCommit(id) - onDismiss() - return .handled - } - } - } - .frame(width: 420) - .fixedSize(horizontal: false, vertical: true) - .task { await fetchForeignKeyValues() } - .onChange(of: searchText) { - selectedId = filteredValues.first?.id - } - } - - // MARK: - Row View - - @ViewBuilder - private func rowLabel(for value: FKValue) -> some View { - if value.id == currentValue { - Text(value.display) - .font(.system(.callout, design: .monospaced)) - .foregroundStyle(.tint) - .lineLimit(1) - .truncationMode(.tail) - } else { - Text(value.display) - .font(.system(.callout, design: .monospaced)) - .foregroundStyle(.primary) - .lineLimit(1) - .truncationMode(.tail) - } - } - - // MARK: - Data Fetching - - private func fetchForeignKeyValues() async { - guard let driver = DatabaseManager.shared.driver(for: connectionId) else { - Self.logger.error("No active driver for FK lookup") - isLoading = false - return - } - - let quotedTable: String - if let schema = fkInfo.referencedSchema { - quotedTable = "\(driver.quoteIdentifier(schema)).\(driver.quoteIdentifier(fkInfo.referencedTable))" - } else { - quotedTable = driver.quoteIdentifier(fkInfo.referencedTable) - } - let quotedColumn = driver.quoteIdentifier(fkInfo.referencedColumn) - - var displayColumn: String? - do { - let columnInfos = try await driver.fetchColumns(table: fkInfo.referencedTable, schema: fkInfo.referencedSchema) - displayColumn = columnInfos.first(where: { col in - col.name != fkInfo.referencedColumn && - !col.isPrimaryKey && - isTextLikeType(col.dataType) - })?.name - } catch { - Self.logger.debug("Could not fetch columns for display: \(error.localizedDescription)") - } - - let query: String - let limitSuffix: String - switch PluginManager.shared.paginationStyle(for: databaseType) { - case .offsetFetch: - limitSuffix = "OFFSET 0 ROWS FETCH NEXT \(Self.maxFetchRows) ROWS ONLY" - case .limit: - limitSuffix = "LIMIT \(Self.maxFetchRows)" - } - if let displayCol = displayColumn { - let quotedDisplay = driver.quoteIdentifier(displayCol) - query = "SELECT \(quotedColumn), \(quotedDisplay) FROM \(quotedTable) ORDER BY \(quotedColumn) \(limitSuffix)" - } else { - query = "SELECT DISTINCT \(quotedColumn) FROM \(quotedTable) ORDER BY \(quotedColumn) \(limitSuffix)" - } - - do { - let result = try await driver.execute(query: query) - var values: [FKValue] = [] - for row in result.rows { - guard !row.isEmpty, let idVal = row[0].asText else { continue } - let displayVal: String - if displayColumn != nil, row.count > 1, let second = row[1].asText { - displayVal = "\(idVal), \(second)" - } else { - displayVal = idVal - } - values.append(FKValue(id: idVal, display: displayVal)) - } - allValues = values - selectedId = currentValue - } catch { - Self.logger.error("FK value fetch failed: \(error.localizedDescription)") - } - - isLoading = false - } - - // MARK: - Helpers - - private func isTextLikeType(_ typeString: String) -> Bool { - let upper = typeString.uppercased() - return upper.contains("CHAR") || upper.contains("TEXT") || upper.contains("NAME") - } -} - -// MARK: - FK Value Model - -private struct FKValue: Identifiable, Hashable { - let id: String - let display: String -} diff --git a/TablePro/Views/Results/HexEditorContentView.swift b/TablePro/Views/Results/HexEditorContentView.swift index d45757f63..f8ab5e126 100644 --- a/TablePro/Views/Results/HexEditorContentView.swift +++ b/TablePro/Views/Results/HexEditorContentView.swift @@ -10,6 +10,7 @@ import SwiftUI struct HexEditorContentView: View { let initialValue: String? + let isEditable: Bool let onCommit: (String) -> Void let onCommitBytes: ((Data) -> Void)? let onDismiss: () -> Void @@ -23,11 +24,13 @@ struct HexEditorContentView: View { init( initialValue: String?, - onCommit: @escaping (String) -> Void, + isEditable: Bool = true, + onCommit: @escaping (String) -> Void = { _ in }, onCommitBytes: ((Data) -> Void)? = nil, onDismiss: @escaping () -> Void ) { self.initialValue = initialValue + self.isEditable = isEditable self.onCommit = onCommit self.onCommitBytes = onCommitBytes self.onDismiss = onDismiss @@ -52,51 +55,66 @@ struct HexEditorContentView: View { VStack(spacing: 0) { HexDumpDisplayView(text: hexDumpText) - Divider() + if isEditable { + Divider() - VStack(alignment: .leading, spacing: 4) { - Text("Editable Hex") - .font(.caption) - .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 4) { + Text("Editable Hex") + .font(.caption) + .foregroundStyle(.secondary) + + HexInputTextView(text: $editableHex) + .frame(height: 80) + + HStack(spacing: 4) { + Text("\(byteCount) bytes") + .font(.caption) + .foregroundStyle(.tertiary) + + if isTruncated { + Text(String(localized: "Truncated, read only")) + .font(.caption) + .foregroundStyle(.orange) + } else if !isValid, !editableHex.isEmpty { + Text(String(localized: "Invalid hex")) + .font(.caption) + .foregroundStyle(.red) + } + + Spacer() + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) - HexInputTextView(text: $editableHex) - .frame(height: 80) + Divider() + + HStack { + Spacer() + Button("Cancel") { onDismiss() } + .keyboardShortcut(.cancelAction) + Button("Save") { saveHex() } + .keyboardShortcut(.defaultAction) + .disabled(!isValid || isTruncated) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } else { + Divider() HStack(spacing: 4) { Text("\(byteCount) bytes") .font(.caption) .foregroundStyle(.tertiary) - - if isTruncated { - Text(String(localized: "Truncated — read only")) - .font(.caption) - .foregroundStyle(.orange) - } else if !isValid, !editableHex.isEmpty { - Text(String(localized: "Invalid hex")) - .font(.caption) - .foregroundStyle(.red) - } - Spacer() + Button("Close") { onDismiss() } + .keyboardShortcut(.cancelAction) } + .padding(.horizontal, 12) + .padding(.vertical, 8) } - .padding(.horizontal, 12) - .padding(.vertical, 8) - - Divider() - - HStack { - Spacer() - Button("Cancel") { onDismiss() } - .keyboardShortcut(.cancelAction) - Button("Save") { saveHex() } - .keyboardShortcut(.defaultAction) - .disabled(!isValid || isTruncated) - } - .padding(.horizontal, 12) - .padding(.vertical, 8) } - .frame(width: 520, height: 400) + .frame(width: 520, height: isEditable ? 400 : 280) .onChange(of: editableHex) { _, newValue in scheduleValidation(newValue) } diff --git a/TablePro/Views/Results/JSONViewerContentView.swift b/TablePro/Views/Results/JSONViewerContentView.swift new file mode 100644 index 000000000..428f384ca --- /dev/null +++ b/TablePro/Views/Results/JSONViewerContentView.swift @@ -0,0 +1,39 @@ +// +// JSONViewerContentView.swift +// TablePro +// + +import SwiftUI + +struct JSONViewerContentView: View { + let initialValue: String? + let columnName: String? + let onDismiss: () -> Void + var onPopOut: ((String) -> Void)? + + @State private var text: String + + init( + initialValue: String?, + columnName: String? = nil, + onDismiss: @escaping () -> Void, + onPopOut: ((String) -> Void)? = nil + ) { + self.initialValue = initialValue + self.columnName = columnName + self.onDismiss = onDismiss + self.onPopOut = onPopOut + self._text = State(initialValue: initialValue?.prettyPrintedAsJson() ?? initialValue ?? "") + } + + var body: some View { + JSONViewerView( + text: $text, + isEditable: false, + onDismiss: onDismiss, + onPopOut: onPopOut + ) + .frame(width: 560) + .frame(minHeight: 200, maxHeight: 480) + } +} diff --git a/TablePro/Views/Results/KeyHandlingTableView.swift b/TablePro/Views/Results/KeyHandlingTableView.swift index 47fcd4e54..c8a807f5b 100644 --- a/TablePro/Views/Results/KeyHandlingTableView.swift +++ b/TablePro/Views/Results/KeyHandlingTableView.swift @@ -4,6 +4,7 @@ final class KeyHandlingTableView: NSTableView { weak var coordinator: TableViewCoordinator? private var isRaisingOverlayEditor = false + private var isRaisingOverlayViewer = false override var acceptsFirstResponder: Bool { true @@ -11,16 +12,21 @@ final class KeyHandlingTableView: NSTableView { override func didAddSubview(_ subview: NSView) { super.didAddSubview(subview) - guard !isRaisingOverlayEditor else { return } - guard let editor = coordinator?.overlayEditor, - editor.isActive, - let container = editor.containerView, + raiseOverlayIfNeeded(coordinator?.overlayEditor, subview: subview, flag: &isRaisingOverlayEditor) + raiseOverlayIfNeeded(coordinator?.overlayViewer, subview: subview, flag: &isRaisingOverlayViewer) + } + + private func raiseOverlayIfNeeded(_ overlay: CellOverlayBase?, subview: NSView, flag: inout Bool) { + guard !flag else { return } + guard let overlay, + overlay.isActive, + let container = overlay.containerView, container !== subview, container.superview === self, subviews.last !== container else { return } - isRaisingOverlayEditor = true - editor.raiseToFront() - isRaisingOverlayEditor = false + flag = true + overlay.raiseToFront() + flag = false } var selection = TableSelection() { @@ -161,7 +167,7 @@ final class KeyHandlingTableView: NSTableView { case #selector(paste(_:)): return coordinator?.isEditable == true && coordinator?.delegate != nil case #selector(insertNewline(_:)): - return selectedRow >= 0 && DataGridView.isDataTableColumn(focusedColumn) && coordinator?.isEditable == true + return selectedRow >= 0 && DataGridView.isDataTableColumn(focusedColumn) case #selector(cancelOperation(_:)): return false default: @@ -233,36 +239,12 @@ final class KeyHandlingTableView: NSTableView { let row = selectedRow guard row >= 0, DataGridView.isDataTableColumn(focusedColumn), - coordinator?.isEditable == true, let schema = coordinator?.identitySchema, let columnIndex = DataGridView.dataColumnIndex(for: focusedColumn, in: self, schema: schema), let coordinator else { return } - - // Dropdown / type-picker columns: Return opens the popup, matching the - // chevron and double-click paths. Without this branch, Return on a focused - // dropdown cell does nothing because beginCellEdit is blocked by editEligibility. - if coordinator.dropdownColumns?.contains(columnIndex) == true || - coordinator.typePickerColumns?.contains(columnIndex) == true { - coordinator.handleChevronAction(row: row, columnIndex: columnIndex) - return - } - - let tableRows = coordinator.tableRowsProvider() - if columnIndex < tableRows.columnTypes.count, - tableRows.columnTypes[columnIndex].isBlobType { - coordinator.showBlobEditorPopover(tableView: self, row: row, column: focusedColumn, columnIndex: columnIndex) - return - } - - if let value = coordinator.cellValue(at: row, column: columnIndex), - value.containsLineBreak { - coordinator.showOverlayEditor(tableView: self, row: row, column: focusedColumn, columnIndex: columnIndex, value: value) - return - } - - coordinator.beginCellEdit(row: row, tableColumnIndex: focusedColumn) + coordinator.handleCellInteraction(row: row, tableColumn: focusedColumn, columnIndex: columnIndex, tableView: self) } @objc override func cancelOperation(_ sender: Any?) { diff --git a/TableProTests/Views/Results/CellInteractionResolverTests.swift b/TableProTests/Views/Results/CellInteractionResolverTests.swift new file mode 100644 index 000000000..042e4e882 --- /dev/null +++ b/TableProTests/Views/Results/CellInteractionResolverTests.swift @@ -0,0 +1,160 @@ +// +// CellInteractionResolverTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("CellInteractionResolver - read-only path") +struct CellInteractionResolverReadOnlyTests { + private let resolver = CellInteractionResolver() + + @Test("deleted row returns blocked regardless of editability") + func deletedRowReturnsBlocked() { + let context = ContextFactory.make(value: "hello", isTableEditable: false, isRowDeleted: true) + #expect(resolver.resolve(context) == .blocked) + } + + @Test("deleted row blocked even in editable table") + func deletedRowBlockedInEditableTable() { + let context = ContextFactory.make(value: "hello", isTableEditable: true, isRowDeleted: true) + #expect(resolver.resolve(context) == .blocked) + } + + @Test("read-only plain text returns viewInline with value") + func readOnlyPlainTextReturnsViewInline() { + let context = ContextFactory.make(value: "hello", isTableEditable: false) + #expect(resolver.resolve(context) == .viewInline(value: "hello")) + } + + @Test("read-only single-line text returns viewInline") + func readOnlySingleLineReturnsViewInline() { + let context = ContextFactory.make(value: "A", isTableEditable: false) + #expect(resolver.resolve(context) == .viewInline(value: "A")) + } + + @Test("read-only nil value returns viewInline with NULL placeholder") + func readOnlyNilValueReturnsViewInlineWithNull() { + let context = ContextFactory.make(value: nil, isTableEditable: false) + #expect(resolver.resolve(context) == .viewInline(value: "NULL")) + } + + @Test("read-only multiline text returns viewInline") + func readOnlyMultilineReturnsViewInline() { + let context = ContextFactory.make(value: "line1\nline2", isTableEditable: false) + #expect(resolver.resolve(context) == .viewInline(value: "line1\nline2")) + } + + @Test("read-only JSON column returns viewJson") + func readOnlyJsonColumnReturnsViewJson() { + let context = ContextFactory.make(value: #"{"k":1}"#, columnType: .json(rawType: "JSON"), isTableEditable: false) + #expect(resolver.resolve(context) == .viewJson) + } + + @Test("read-only BLOB column returns viewBlob") + func readOnlyBlobColumnReturnsViewBlob() { + let context = ContextFactory.make(value: nil, columnType: .blob(rawType: "BLOB"), isTableEditable: false) + #expect(resolver.resolve(context) == .viewBlob) + } + + @Test("immutable column on editable table follows read-only path for plain text") + func immutableColumnFollowsReadOnlyPath() { + let context = ContextFactory.make(value: "id-123", isTableEditable: true, isImmutableColumn: true) + #expect(resolver.resolve(context) == .viewInline(value: "id-123")) + } + + @Test("immutable JSON column on editable table still returns viewJson") + func immutableJsonColumnReturnsViewJson() { + let context = ContextFactory.make(value: "{}", columnType: .json(rawType: "JSON"), isTableEditable: true, isImmutableColumn: true) + #expect(resolver.resolve(context) == .viewJson) + } + + @Test("read-only JSON-looking plain text without columnType returns viewInline, not viewJson") + func readOnlyJsonLikeTextWithoutTypeReturnsViewInline() { + let context = ContextFactory.make(value: #"{"k":1}"#, columnType: nil, isTableEditable: false) + #expect(resolver.resolve(context) == .viewInline(value: #"{"k":1}"#)) + } +} + +@Suite("CellInteractionResolver - editable path") +struct CellInteractionResolverEditableTests { + private let resolver = CellInteractionResolver() + + @Test("editable plain single-line returns editInline") + func editablePlainSingleLineReturnsEditInline() { + let context = ContextFactory.make(value: "hello", isTableEditable: true) + #expect(resolver.resolve(context) == .editInline(value: "hello")) + } + + @Test("editable plain multiline returns editOverlay") + func editablePlainMultilineReturnsEditOverlay() { + let context = ContextFactory.make(value: "line1\nline2", isTableEditable: true) + #expect(resolver.resolve(context) == .editOverlay(value: "line1\nline2")) + } + + @Test("editable plain text that looks like JSON returns editJson") + func editableJsonLikeTextReturnsEditJson() { + let context = ContextFactory.make(value: #"{"k":1}"#, isTableEditable: true) + #expect(resolver.resolve(context) == .editJson) + } + + @Test("editable JSON column returns editJson") + func editableJsonColumnReturnsEditJson() { + let context = ContextFactory.make(value: "{}", columnType: .json(rawType: "JSON"), isTableEditable: true) + #expect(resolver.resolve(context) == .editJson) + } + + @Test("editable BLOB column returns editBlob") + func editableBlobColumnReturnsEditBlob() { + let context = ContextFactory.make(value: nil, columnType: .blob(rawType: "BLOB"), isTableEditable: true) + #expect(resolver.resolve(context) == .editBlob) + } + + @Test("editable foreign key column returns editInline (FK popover is not opened by double-click)") + func editableForeignKeyReturnsEditInline() { + let context = ContextFactory.make(value: "1", columnType: .integer(rawType: "INT"), isTableEditable: true) + #expect(resolver.resolve(context) == .editInline(value: "1")) + } + + @Test("editable boolean column returns editInline, not a picker (pickers are chevron-only)") + func editableBooleanColumnReturnsEditInline() { + let context = ContextFactory.make(value: "true", columnType: .boolean(rawType: "BOOL"), isTableEditable: true) + #expect(resolver.resolve(context) == .editInline(value: "true")) + } + + @Test("editable enum column returns editInline, not a picker") + func editableEnumColumnReturnsEditInline() { + let context = ContextFactory.make( + value: "small", + columnType: .enumType(rawType: "ENUM", values: ["small", "medium", "large"]), + isTableEditable: true + ) + #expect(resolver.resolve(context) == .editInline(value: "small")) + } + + @Test("read-only boolean column returns viewInline") + func readOnlyBooleanColumnReturnsViewInline() { + let context = ContextFactory.make(value: "true", columnType: .boolean(rawType: "BOOL"), isTableEditable: false) + #expect(resolver.resolve(context) == .viewInline(value: "true")) + } +} + +private enum ContextFactory { + static func make( + value: String?, + columnType: ColumnType? = nil, + isTableEditable: Bool = false, + isRowDeleted: Bool = false, + isImmutableColumn: Bool = false + ) -> CellContext { + CellContext( + columnType: columnType, + value: value, + isTableEditable: isTableEditable, + isRowDeleted: isRowDeleted, + isImmutableColumn: isImmutableColumn + ) + } +}