Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 20 additions & 1 deletion TablePro/Views/Results/Extensions/DataGridView+Popovers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand All @@ -126,6 +127,7 @@ extension TableViewCoordinator {
) { [weak self] dismiss in
JSONEditorContentView(
initialValue: currentValue,
columnName: columnName,
onCommit: { newValue in
self?.commitPopoverEdit(
tableView: tableView,
Expand All @@ -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
)
}
)
}
)
}
}
Expand Down
11 changes: 9 additions & 2 deletions TablePro/Views/Results/JSONEditorContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? "")
}

Expand All @@ -35,7 +41,8 @@ struct JSONEditorContentView: View {
if normalizedNew != normalizedOld {
onCommit(newValue)
}
}
},
onPopOut: onPopOut
)
.frame(width: 560)
.frame(minHeight: 200, maxHeight: 480)
Expand Down
12 changes: 11 additions & 1 deletion TablePro/Views/Results/JSONViewerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand All @@ -22,12 +23,14 @@ internal struct JSONViewerView: View {
text: Binding<String>,
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)
}

Expand Down Expand Up @@ -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() {
Expand Down
138 changes: 138 additions & 0 deletions TablePro/Views/Results/JSONViewerWindowController.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
3 changes: 2 additions & 1 deletion TablePro/Views/RightSidebar/EditableFieldView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
34 changes: 24 additions & 10 deletions TablePro/Views/RightSidebar/FieldEditors/JsonEditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,39 @@ 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)
.frame(minHeight: context.isReadOnly ? 60 : 80, maxHeight: 120)
.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)
}
}
}
27 changes: 26 additions & 1 deletion TablePro/Views/RightSidebar/RightSidebarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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(
Expand Down Expand Up @@ -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
)
}
}
Expand Down
Loading