From 0966ed34662c45f197455171b66648a742b53ac9 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 10 Jul 2026 14:50:18 +0700 Subject: [PATCH 1/2] feat(datagrid): make the row inspector JSON field resizable with a persisted height (#1849) --- CHANGELOG.md | 4 ++ .../FieldEditors/JsonEditorView.swift | 62 +++++++++-------- .../ResizableEditorContainer.swift | 66 +++++++++++++++++++ .../FieldEditors/ResizableFieldMetrics.swift | 15 +++++ .../ResizableFieldMetricsTests.swift | 37 +++++++++++ docs/features/data-grid.mdx | 2 + 6 files changed, 157 insertions(+), 29 deletions(-) create mode 100644 TablePro/Views/RightSidebar/FieldEditors/ResizableEditorContainer.swift create mode 100644 TablePro/Views/RightSidebar/FieldEditors/ResizableFieldMetrics.swift create mode 100644 TableProTests/Views/RightSidebar/ResizableFieldMetricsTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 50209c9df..5594b99e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- The JSON field in the row details inspector can now be resized by dragging the handle below it. The height is remembered across rows and app restarts. (#1849) + ### Changed - Data grid column comments now appear directly in column headers when object comments are enabled, instead of only being available from the header tooltip. (#1789) diff --git a/TablePro/Views/RightSidebar/FieldEditors/JsonEditorView.swift b/TablePro/Views/RightSidebar/FieldEditors/JsonEditorView.swift index f79cdaf40..541dcf7e3 100644 --- a/TablePro/Views/RightSidebar/FieldEditors/JsonEditorView.swift +++ b/TablePro/Views/RightSidebar/FieldEditors/JsonEditorView.swift @@ -11,6 +11,7 @@ internal struct JsonEditorView: View { var onPopOut: ((String) -> Void)? @State private var displayText: String + @AppStorage("rightSidebar.jsonFieldHeight") private var fieldHeight = ResizableFieldMetrics.defaultJsonHeight init(context: FieldEditorContext, onExpand: (() -> Void)? = nil, onPopOut: ((String) -> Void)? = nil) { self.context = context @@ -20,37 +21,40 @@ internal struct JsonEditorView: View { } var body: some View { - JSONCodeEditor(text: $displayText, isEditable: !context.isReadOnly) - .frame(minHeight: context.isReadOnly ? 60 : 80, maxHeight: 120) - .clipShape(RoundedRectangle(cornerRadius: 5)) - .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color(nsColor: .separatorColor))) - .overlay(alignment: .bottomTrailing) { - HStack(spacing: 2) { - if let onPopOut { - Button { onPopOut(displayText) } label: { - Image(systemName: "arrow.up.forward.app") - .font(.caption2) - .padding(4) - .themeMaterial(.inlineControl, .ultraThinMaterial, in: RoundedRectangle(cornerRadius: 4)) - } - .buttonStyle(.borderless) - .help(String(localized: "Open in Window")) - } - if let onExpand { - Button(action: onExpand) { - Image(systemName: "arrow.up.left.and.arrow.down.right") - .font(.caption2) - .padding(4) - .themeMaterial(.inlineControl, .ultraThinMaterial, in: RoundedRectangle(cornerRadius: 4)) - } - .buttonStyle(.borderless) - .help(String(localized: "Expand in Sidebar")) - } + ResizableEditorContainer(height: $fieldHeight, range: ResizableFieldMetrics.jsonHeightRange) { + JSONCodeEditor(text: $displayText, isEditable: !context.isReadOnly) + .clipShape(RoundedRectangle(cornerRadius: 5)) + .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color(nsColor: .separatorColor))) + .overlay(alignment: .bottomTrailing) { actionButtons } + } + .onChange(of: displayText) { propagateEdit() } + .onChange(of: context.value.wrappedValue) { syncFromBinding() } + } + + private var actionButtons: some View { + HStack(spacing: 2) { + if let onPopOut { + Button { onPopOut(displayText) } label: { + Image(systemName: "arrow.up.forward.app") + .font(.caption2) + .padding(4) + .themeMaterial(.inlineControl, .ultraThinMaterial, in: RoundedRectangle(cornerRadius: 4)) + } + .buttonStyle(.borderless) + .help(String(localized: "Open in Window")) + } + if let onExpand { + Button(action: onExpand) { + Image(systemName: "arrow.up.left.and.arrow.down.right") + .font(.caption2) + .padding(4) + .themeMaterial(.inlineControl, .ultraThinMaterial, in: RoundedRectangle(cornerRadius: 4)) } - .padding(4) + .buttonStyle(.borderless) + .help(String(localized: "Expand in Sidebar")) } - .onChange(of: displayText) { propagateEdit() } - .onChange(of: context.value.wrappedValue) { syncFromBinding() } + } + .padding(4) } private func propagateEdit() { diff --git a/TablePro/Views/RightSidebar/FieldEditors/ResizableEditorContainer.swift b/TablePro/Views/RightSidebar/FieldEditors/ResizableEditorContainer.swift new file mode 100644 index 000000000..0e8a1278a --- /dev/null +++ b/TablePro/Views/RightSidebar/FieldEditors/ResizableEditorContainer.swift @@ -0,0 +1,66 @@ +// +// ResizableEditorContainer.swift +// TablePro +// + +import AppKit +import SwiftUI + +internal struct ResizableEditorContainer: View { + @Binding var height: Double + let range: ClosedRange + @ViewBuilder let content: () -> Content + + @GestureState private var liveDelta: Double = 0 + @State private var isHandleHovered = false + + private var resolvedHeight: Double { + ResizableFieldMetrics.resolve(base: height, delta: liveDelta, range: range) + } + + var body: some View { + VStack(spacing: 2) { + content() + .frame(height: resolvedHeight) + resizeHandle + } + } + + private var resizeHandle: some View { + Capsule() + .fill(Color(nsColor: .tertiaryLabelColor)) + .frame(width: 26, height: 4) + .opacity(isHandleHovered ? 1 : 0.5) + .frame(maxWidth: .infinity, minHeight: 11) + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 1) + .updating($liveDelta) { value, state, _ in + state = value.translation.height + } + .onEnded { value in + height = ResizableFieldMetrics.resolve( + base: height, + delta: value.translation.height, + range: range + ) + } + ) + .onHover { hovering in + guard hovering != isHandleHovered else { return } + isHandleHovered = hovering + if hovering { + NSCursor.resizeUpDown.push() + } else { + NSCursor.pop() + } + } + .onDisappear { + if isHandleHovered { + NSCursor.pop() + isHandleHovered = false + } + } + .accessibilityHidden(true) + } +} diff --git a/TablePro/Views/RightSidebar/FieldEditors/ResizableFieldMetrics.swift b/TablePro/Views/RightSidebar/FieldEditors/ResizableFieldMetrics.swift new file mode 100644 index 000000000..155c37fcb --- /dev/null +++ b/TablePro/Views/RightSidebar/FieldEditors/ResizableFieldMetrics.swift @@ -0,0 +1,15 @@ +// +// ResizableFieldMetrics.swift +// TablePro +// + +import Foundation + +internal enum ResizableFieldMetrics { + static let jsonHeightRange: ClosedRange = 80...600 + static let defaultJsonHeight: Double = 120 + + static func resolve(base: Double, delta: Double, range: ClosedRange) -> Double { + min(max(base + delta, range.lowerBound), range.upperBound) + } +} diff --git a/TableProTests/Views/RightSidebar/ResizableFieldMetricsTests.swift b/TableProTests/Views/RightSidebar/ResizableFieldMetricsTests.swift new file mode 100644 index 000000000..50a56d98e --- /dev/null +++ b/TableProTests/Views/RightSidebar/ResizableFieldMetricsTests.swift @@ -0,0 +1,37 @@ +// +// ResizableFieldMetricsTests.swift +// TableProTests +// + +import XCTest + +@testable import TablePro + +final class ResizableFieldMetricsTests: XCTestCase { + private let range: ClosedRange = 80...600 + + func testResolveAddsDeltaWithinRange() { + XCTAssertEqual(ResizableFieldMetrics.resolve(base: 120, delta: 40, range: range), 160) + } + + func testResolveClampsToLowerBound() { + XCTAssertEqual(ResizableFieldMetrics.resolve(base: 100, delta: -200, range: range), 80) + } + + func testResolveClampsToUpperBound() { + XCTAssertEqual(ResizableFieldMetrics.resolve(base: 500, delta: 400, range: range), 600) + } + + func testResolveNegativeDeltaStaysWithinRange() { + XCTAssertEqual(ResizableFieldMetrics.resolve(base: 300, delta: -100, range: range), 200) + } + + func testResolveHonoursExactBounds() { + XCTAssertEqual(ResizableFieldMetrics.resolve(base: 80, delta: 0, range: range), 80) + XCTAssertEqual(ResizableFieldMetrics.resolve(base: 600, delta: 0, range: range), 600) + } + + func testDefaultJsonHeightIsWithinJsonRange() { + XCTAssertTrue(ResizableFieldMetrics.jsonHeightRange.contains(ResizableFieldMetrics.defaultJsonHeight)) + } +} diff --git a/docs/features/data-grid.mdx b/docs/features/data-grid.mdx index 44ab44a84..200048b74 100644 --- a/docs/features/data-grid.mdx +++ b/docs/features/data-grid.mdx @@ -113,6 +113,8 @@ See [Change Tracking](/features/change-tracking) for details. Toggle the inspector with `Cmd+Shift+B`. With a row selected, it lists every column value with a full editor for long text and JSON. With no row selected, it shows table metadata: row count, size, engine, charset, and collation. +Drag the handle below a JSON field to make it taller and see more content without leaving the inspector. The height is remembered across rows and app restarts. For very large values, use **Expand in Sidebar** or **Open in Window**. + Cell Inspector Cell Inspector From b054bbf2849fc45f94e96889cfb1e302a4a6a597 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 10 Jul 2026 14:53:15 +0700 Subject: [PATCH 2/2] fix(datagrid): measure the JSON resize drag in global space to stop jitter (#1849) --- .../RightSidebar/FieldEditors/ResizableEditorContainer.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TablePro/Views/RightSidebar/FieldEditors/ResizableEditorContainer.swift b/TablePro/Views/RightSidebar/FieldEditors/ResizableEditorContainer.swift index 0e8a1278a..960f86e38 100644 --- a/TablePro/Views/RightSidebar/FieldEditors/ResizableEditorContainer.swift +++ b/TablePro/Views/RightSidebar/FieldEditors/ResizableEditorContainer.swift @@ -34,7 +34,7 @@ internal struct ResizableEditorContainer: View { .frame(maxWidth: .infinity, minHeight: 11) .contentShape(Rectangle()) .gesture( - DragGesture(minimumDistance: 1) + DragGesture(minimumDistance: 1, coordinateSpace: .global) .updating($liveDelta) { value, state, _ in state = value.translation.height }