From 0ec21220d1c8687873af390b70eb006cb78f0a2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 24 Apr 2026 17:42:11 +0700 Subject: [PATCH] feat: add "Open in Window" action to JSON viewer for resizing and fullscreen --- CHANGELOG.md | 1 + .../Extensions/DataGridView+Popovers.swift | 21 ++- .../Views/Results/JSONEditorContentView.swift | 11 +- TablePro/Views/Results/JSONViewerView.swift | 12 +- .../Results/JSONViewerWindowController.swift | 138 ++++++++++++++++++ .../RightSidebar/EditableFieldView.swift | 3 +- .../FieldEditors/JsonEditorView.swift | 34 +++-- .../Views/RightSidebar/RightSidebarView.swift | 27 +++- 8 files changed, 231 insertions(+), 16 deletions(-) create mode 100644 TablePro/Views/Results/JSONViewerWindowController.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c0aa668a..0fbfcb168 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- JSON viewer: "Open in Window" action to pop out JSON into a resizable, fullscreen-capable window - In-app feedback form for bug reports and feature requests via Help > Report an Issue - Per-connection "Local only" option to exclude individual connections from iCloud sync - Filter operator picker shows SQL symbols alongside names for quick visual recognition diff --git a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift index 539c03d65..f8ea64341 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift @@ -115,6 +115,7 @@ extension TableViewCoordinator { func showJSONEditorPopover(tableView: NSTableView, row: Int, column: Int, columnIndex: Int) { let currentValue = rowProvider.value(atRow: row, column: columnIndex) + let columnName = rowProvider.columns[columnIndex] guard tableView.view(atColumn: column, row: row, makeIfNecessary: false) != nil else { return } @@ -126,6 +127,7 @@ extension TableViewCoordinator { ) { [weak self] dismiss in JSONEditorContentView( initialValue: currentValue, + columnName: columnName, onCommit: { newValue in self?.commitPopoverEdit( tableView: tableView, @@ -135,7 +137,24 @@ extension TableViewCoordinator { newValue: newValue ) }, - onDismiss: dismiss + onDismiss: dismiss, + onPopOut: { currentText in + dismiss() + JSONViewerWindowController.open( + text: currentText, + columnName: columnName, + isEditable: true, + onCommit: { newValue in + self?.commitPopoverEdit( + tableView: tableView, + row: row, + column: column, + columnIndex: columnIndex, + newValue: newValue + ) + } + ) + } ) } } diff --git a/TablePro/Views/Results/JSONEditorContentView.swift b/TablePro/Views/Results/JSONEditorContentView.swift index 54e8ec74e..cbdac1fb4 100644 --- a/TablePro/Views/Results/JSONEditorContentView.swift +++ b/TablePro/Views/Results/JSONEditorContentView.swift @@ -7,19 +7,25 @@ import SwiftUI struct JSONEditorContentView: View { let initialValue: String? + let columnName: String? let onCommit: (String) -> Void let onDismiss: () -> Void + var onPopOut: ((String) -> Void)? @State private var text: String init( initialValue: String?, + columnName: String? = nil, onCommit: @escaping (String) -> Void, - onDismiss: @escaping () -> Void + onDismiss: @escaping () -> Void, + onPopOut: ((String) -> Void)? = nil ) { self.initialValue = initialValue + self.columnName = columnName self.onCommit = onCommit self.onDismiss = onDismiss + self.onPopOut = onPopOut self._text = State(initialValue: initialValue?.prettyPrintedAsJson() ?? initialValue ?? "") } @@ -35,7 +41,8 @@ struct JSONEditorContentView: View { if normalizedNew != normalizedOld { onCommit(newValue) } - } + }, + onPopOut: onPopOut ) .frame(width: 560) .frame(minHeight: 200, maxHeight: 480) diff --git a/TablePro/Views/Results/JSONViewerView.swift b/TablePro/Views/Results/JSONViewerView.swift index dd2051ce5..5c336307e 100644 --- a/TablePro/Views/Results/JSONViewerView.swift +++ b/TablePro/Views/Results/JSONViewerView.swift @@ -10,6 +10,7 @@ internal struct JSONViewerView: View { let isEditable: Bool var onDismiss: (() -> Void)? var onCommit: ((String) -> Void)? + var onPopOut: ((String) -> Void)? @State private var viewMode: JSONViewMode @State private var treeSearchText = "" @@ -22,12 +23,14 @@ internal struct JSONViewerView: View { text: Binding, isEditable: Bool, onDismiss: (() -> Void)? = nil, - onCommit: ((String) -> Void)? = nil + onCommit: ((String) -> Void)? = nil, + onPopOut: ((String) -> Void)? = nil ) { self._text = text self.isEditable = isEditable self.onDismiss = onDismiss self.onCommit = onCommit + self.onPopOut = onPopOut self._viewMode = State(initialValue: AppSettingsManager.shared.editor.jsonViewerPreferredMode) } @@ -65,6 +68,13 @@ internal struct JSONViewerView: View { .pickerStyle(.segmented) .fixedSize() Spacer() + if let onPopOut { + Button { onPopOut(text) } label: { + Image(systemName: "arrow.up.forward.app") + } + .buttonStyle(.borderless) + .help(String(localized: "Open in Window")) + } if viewMode == .text && isEditable { Button { if let formatted = text.prettyPrintedAsJson() { diff --git a/TablePro/Views/Results/JSONViewerWindowController.swift b/TablePro/Views/Results/JSONViewerWindowController.swift new file mode 100644 index 000000000..efc77d441 --- /dev/null +++ b/TablePro/Views/Results/JSONViewerWindowController.swift @@ -0,0 +1,138 @@ +// +// JSONViewerWindowController.swift +// TablePro +// + +import AppKit +import SwiftUI + +@MainActor +final class JSONViewerWindowController { + private static var activeWindows: [ObjectIdentifier: JSONViewerWindowController] = [:] + private static var lastCascadePoint: NSPoint = .zero + private static let defaultSize = NSSize(width: 640, height: 500) + private static let minSize = NSSize(width: 400, height: 300) + private static let sizeKey = "JSONViewerWindow.size" + + private var window: NSWindow? + private var closeObserver: NSObjectProtocol? + + static func open( + text: String?, + columnName: String?, + isEditable: Bool, + onCommit: ((String) -> Void)? + ) { + let controller = JSONViewerWindowController() + controller.showWindow(text: text, columnName: columnName, isEditable: isEditable, onCommit: onCommit) + } + + private func showWindow( + text: String?, + columnName: String?, + isEditable: Bool, + onCommit: ((String) -> Void)? + ) { + let savedSize = UserDefaults.standard.size(forKey: Self.sizeKey) ?? Self.defaultSize + + let window = NSWindow( + contentRect: NSRect(origin: .zero, size: savedSize), + styleMask: [.titled, .closable, .resizable, .miniaturizable], + backing: .buffered, + defer: false + ) + window.identifier = NSUserInterfaceItemIdentifier("json-viewer") + window.title = columnName.map { "JSON — \($0)" } ?? String(localized: "JSON Viewer") + window.isReleasedWhenClosed = false + window.minSize = Self.minSize + window.collectionBehavior = [.fullScreenPrimary] + + let closeWindow: () -> Void = { [weak window] in window?.close() } + let contentView = JSONViewerWindowContent( + initialValue: text, + isEditable: isEditable, + onCommit: onCommit, + onDismiss: closeWindow + ) + window.contentView = NSHostingView(rootView: contentView) + + self.window = window + + let key = ObjectIdentifier(self) + Self.activeWindows[key] = self + + closeObserver = NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, + object: window, + queue: .main + ) { [weak self] notification in + Task { @MainActor in + if let closingWindow = notification.object as? NSWindow { + UserDefaults.standard.set(closingWindow.frame.size, forKey: Self.sizeKey) + } + Self.activeWindows.removeValue(forKey: key) + self?.closeObserver.map { NotificationCenter.default.removeObserver($0) } + self?.closeObserver = nil + self?.window = nil + } + } + + Self.lastCascadePoint = window.cascadeTopLeft(from: Self.lastCascadePoint) + window.makeKeyAndOrderFront(nil) + } +} + +// MARK: - Window Content + +private struct JSONViewerWindowContent: View { + let initialValue: String? + let isEditable: Bool + let onCommit: ((String) -> Void)? + let onDismiss: (() -> Void)? + + @State private var text: String + + init( + initialValue: String?, + isEditable: Bool, + onCommit: ((String) -> Void)?, + onDismiss: (() -> Void)? + ) { + self.initialValue = initialValue + self.isEditable = isEditable + self.onCommit = onCommit + self.onDismiss = onDismiss + self._text = State(initialValue: initialValue?.prettyPrintedAsJson() ?? initialValue ?? "") + } + + var body: some View { + JSONViewerView( + text: $text, + isEditable: isEditable, + onDismiss: onDismiss, + onCommit: isEditable ? { newValue in + if newValue.isEmpty && initialValue == nil { return } + let normalizedNew = JSONViewerView.compact(newValue) + let normalizedOld = JSONViewerView.compact(initialValue) + if normalizedNew != normalizedOld { + onCommit?(newValue) + } + } : nil + ) + } +} + +// MARK: - UserDefaults + NSSize + +private extension UserDefaults { + func size(forKey key: String) -> NSSize? { + guard let string = string(forKey: key) else { return nil } + let size = NSSizeFromString(string) + guard size.width > 0, size.height > 0 else { return nil } + return size + } + + func set(_ size: NSSize, forKey key: String) { + set(NSStringFromSize(size), forKey: key) + } +} diff --git a/TablePro/Views/RightSidebar/EditableFieldView.swift b/TablePro/Views/RightSidebar/EditableFieldView.swift index 41c123a82..080a42d38 100644 --- a/TablePro/Views/RightSidebar/EditableFieldView.swift +++ b/TablePro/Views/RightSidebar/EditableFieldView.swift @@ -21,6 +21,7 @@ internal struct FieldDetailView: View { let onSetEmpty: () -> Void let onSetFunction: (String) -> Void var onExpand: (() -> Void)? + var onPopOut: ((String) -> Void)? @State private var isHovered = false @@ -118,7 +119,7 @@ internal struct FieldDetailView: View { private func resolvedEditor(for kind: FieldEditorKind) -> some View { switch kind { case .json: - JsonEditorView(context: context, onExpand: onExpand) + JsonEditorView(context: context, onExpand: onExpand, onPopOut: onPopOut) case .blobHex: BlobHexEditorView(context: context) case .boolean: diff --git a/TablePro/Views/RightSidebar/FieldEditors/JsonEditorView.swift b/TablePro/Views/RightSidebar/FieldEditors/JsonEditorView.swift index 8eeebf366..d26713e8a 100644 --- a/TablePro/Views/RightSidebar/FieldEditors/JsonEditorView.swift +++ b/TablePro/Views/RightSidebar/FieldEditors/JsonEditorView.swift @@ -8,6 +8,7 @@ import SwiftUI internal struct JsonEditorView: View { let context: FieldEditorContext var onExpand: (() -> Void)? + var onPopOut: ((String) -> Void)? var body: some View { JSONSyntaxTextView(text: context.value, isEditable: !context.isReadOnly, wordWrap: true) @@ -15,18 +16,31 @@ internal struct JsonEditorView: View { .clipShape(RoundedRectangle(cornerRadius: 5)) .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color(nsColor: .separatorColor))) .overlay(alignment: .bottomTrailing) { - if let onExpand { - Button(action: onExpand) { - Image(systemName: "arrow.up.left.and.arrow.down.right") - .font(.system(size: 10)) - .padding(4) - .background(.ultraThinMaterial) - .clipShape(RoundedRectangle(cornerRadius: 4)) + HStack(spacing: 2) { + if let onPopOut { + Button { onPopOut(context.value.wrappedValue) } label: { + Image(systemName: "arrow.up.forward.app") + .font(.system(size: 10)) + .padding(4) + .background(.ultraThinMaterial) + .clipShape(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(.system(size: 10)) + .padding(4) + .background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } + .buttonStyle(.borderless) + .help(String(localized: "Expand in Sidebar")) } - .buttonStyle(.borderless) - .padding(4) - .help(String(localized: "Open JSON Viewer")) } + .padding(4) } } } diff --git a/TablePro/Views/RightSidebar/RightSidebarView.swift b/TablePro/Views/RightSidebar/RightSidebarView.swift index f610cd402..c5aa2bd8c 100644 --- a/TablePro/Views/RightSidebar/RightSidebarView.swift +++ b/TablePro/Views/RightSidebar/RightSidebarView.swift @@ -160,6 +160,14 @@ struct RightSidebarView: View { Spacer() + Button { + popOutJsonField(field: field, isEditable: isEditable) + } label: { + Image(systemName: "arrow.up.forward.app") + } + .buttonStyle(.borderless) + .help(String(localized: "Open in Window")) + Text(field.columnTypeEnum.badgeLabel) .font(.system(size: 9, weight: .medium)) .foregroundStyle(.tertiary) @@ -183,6 +191,20 @@ struct RightSidebarView: View { } } + private func popOutJsonField(text: String? = nil, field: FieldEditState, isEditable: Bool) { + let text = text ?? field.pendingValue ?? field.originalValue + let fieldId = field.id + JSONViewerWindowController.open( + text: text, + columnName: field.columnName, + isEditable: isEditable, + onCommit: isEditable ? { [editState] newValue in + guard let current = editState.fields.first(where: { $0.id == fieldId }) else { return } + editState.updateField(at: current.columnIndex, value: newValue) + } : nil + ) + } + // MARK: - Field List private func fieldListForm( @@ -276,7 +298,10 @@ struct RightSidebarView: View { onSetDefault: { editState.setFieldToDefault(at: index) }, onSetEmpty: { editState.setFieldToEmpty(at: index) }, onSetFunction: { editState.setFieldToFunction(at: index, function: $0) }, - onExpand: isJsonField ? { expandedJsonFieldId = field.id } : nil + onExpand: isJsonField ? { expandedJsonFieldId = field.id } : nil, + onPopOut: isJsonField ? { currentText in + popOutJsonField(text: currentText, field: field, isEditable: isEditable) + } : nil ) } }