From c84a7f524993af4940eb16a1e61698e559f9efcb Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 23:53:47 +0700 Subject: [PATCH 1/3] feat: add JSON results view mode (#856) --- CHANGELOG.md | 1 + .../Infrastructure/SessionStateFactory.swift | 2 +- TablePro/Models/Query/EditorTabPayload.swift | 2 +- TablePro/Models/Query/QueryTab.swift | 14 +- TablePro/Models/Query/QueryTabManager.swift | 2 +- .../Main/Child/MainEditorContentView.swift | 139 ++++++++++-------- .../Views/Main/Child/MainStatusBarView.swift | 32 ++-- .../MainContentCoordinator+Navigation.swift | 6 +- .../MainContentCoordinator+Refresh.swift | 2 +- .../MainContentCoordinator+SQLPreview.swift | 2 +- .../Extensions/MainContentView+Bindings.swift | 10 +- .../Main/MainContentCommandActions.swift | 12 +- TablePro/Views/Results/ResultsJsonView.swift | 73 +++++++++ 13 files changed, 201 insertions(+), 96 deletions(-) create mode 100644 TablePro/Views/Results/ResultsJsonView.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d9447672..01b90fd99 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 results view mode with Data/Structure/JSON toggle in status bar - Import URL: dynamic placeholder, parsed preview, clipboard auto-paste, libSQL/D1 support, URL schemes for Oracle/ClickHouse/etcd/D1/libSQL - 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 diff --git a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift index 350fe0302..b4b8277b2 100644 --- a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift +++ b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift @@ -93,7 +93,7 @@ enum SessionStateFactory { tabMgr.tabs[index].isEditable = !payload.isView tabMgr.tabs[index].schemaName = payload.schemaName if payload.showStructure { - tabMgr.tabs[index].showStructure = true + tabMgr.tabs[index].resultsViewMode = .structure } if let initialFilter = payload.initialFilterState { tabMgr.tabs[index].filterState = initialFilter diff --git a/TablePro/Models/Query/EditorTabPayload.swift b/TablePro/Models/Query/EditorTabPayload.swift index f0ca47842..5a3b2d1ae 100644 --- a/TablePro/Models/Query/EditorTabPayload.swift +++ b/TablePro/Models/Query/EditorTabPayload.swift @@ -154,7 +154,7 @@ internal struct EditorTabPayload: Codable, Hashable { self.schemaName = tab.schemaName self.initialQuery = tab.query self.isView = tab.isView - self.showStructure = tab.showStructure + self.showStructure = tab.resultsViewMode == .structure self.skipAutoExecute = skipAutoExecute self.isPreview = false self.initialFilterState = nil diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index 3e56804b8..18988c058 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -10,6 +10,12 @@ import Observation import os import TableProPluginKit +enum ResultsViewMode: String, Equatable { + case data + case structure + case json +} + /// Represents a single tab (query or table) struct QueryTab: Identifiable, Equatable { let id: UUID @@ -73,7 +79,7 @@ struct QueryTab: Identifiable, Equatable { var isView: Bool // True for database views (read-only) var databaseName: String // Database this tab was opened in (for multi-database restore) var schemaName: String? // Schema this tab was opened in (for multi-schema restore, e.g. PostgreSQL) - var showStructure: Bool // Toggle to show structure view instead of data + var resultsViewMode: ResultsViewMode = .data var erDiagramSchemaKey: String? var explainText: String? var explainExecutionTime: TimeInterval? @@ -165,7 +171,7 @@ struct QueryTab: Identifiable, Equatable { self.isView = false self.databaseName = "" self.schemaName = nil - self.showStructure = false + self.resultsViewMode = .data self.pendingChanges = TabPendingChanges() self.selectedRowIndices = [] self.sortState = SortState() @@ -201,7 +207,7 @@ struct QueryTab: Identifiable, Equatable { self.isView = persisted.isView self.databaseName = persisted.databaseName self.schemaName = persisted.schemaName - self.showStructure = false + self.resultsViewMode = .data self.erDiagramSchemaKey = persisted.erDiagramSchemaKey self.pendingChanges = TabPendingChanges() self.selectedRowIndices = [] @@ -297,7 +303,7 @@ struct QueryTab: Identifiable, Equatable { && lhs.paginationVersion == rhs.paginationVersion && lhs.pagination == rhs.pagination && lhs.sortState == rhs.sortState - && lhs.showStructure == rhs.showStructure + && lhs.resultsViewMode == rhs.resultsViewMode && lhs.isEditable == rhs.isEditable && lhs.isView == rhs.isView && lhs.tabType == rhs.tabType diff --git a/TablePro/Models/Query/QueryTabManager.swift b/TablePro/Models/Query/QueryTabManager.swift index 6bddb445c..f0cd51b3f 100644 --- a/TablePro/Models/Query/QueryTabManager.swift +++ b/TablePro/Models/Query/QueryTabManager.swift @@ -224,7 +224,7 @@ final class QueryTabManager { tab.statusMessage = nil tab.errorMessage = nil tab.lastExecutedAt = nil - tab.showStructure = false + tab.resultsViewMode = .data tab.sortState = SortState() tab.selectedRowIndices = [] tab.pendingChanges = TabPendingChanges() diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index dddf2dd4a..bf79df6d4 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -367,74 +367,87 @@ struct MainEditorContentView: View { @ViewBuilder private func resultsSection(tab: QueryTab) -> some View { VStack(spacing: 0) { - if tab.showStructure, let tableName = tab.tableName { - TableStructureView( - tableName: tableName, connection: connection, - toolbarState: coordinator.toolbarState, coordinator: coordinator - ) - .id(tableName) - .frame(maxHeight: .infinity) - } else if let explainText = tab.explainText { - ExplainResultView(text: explainText, executionTime: tab.explainExecutionTime, plan: tab.explainPlan) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - // Result tab bar (when multiple result sets) - if tab.resultSets.count > 1 { - resultTabBar(tab: tab) - Divider() - } - - // Inline error banner (when active result set has error) - if let error = tab.activeResultSet?.errorMessage { - InlineErrorBanner( - message: error, - onDismiss: { tab.activeResultSet?.errorMessage = nil } + switch tab.resultsViewMode { + case .structure: + if let tableName = tab.tableName { + TableStructureView( + tableName: tableName, connection: connection, + toolbarState: coordinator.toolbarState, coordinator: coordinator ) - Divider() + .id(tableName) + .frame(maxHeight: .infinity) } - - // Content: success view OR filter+grid - if let rs = tab.activeResultSet, rs.resultColumns.isEmpty, - rs.errorMessage == nil, tab.lastExecutedAt != nil, !tab.isExecuting - { - ResultSuccessView( - rowsAffected: rs.rowsAffected, - executionTime: rs.executionTime, - statusMessage: rs.statusMessage - ) - } else if tab.resultColumns.isEmpty && tab.errorMessage == nil - && tab.lastExecutedAt != nil && !tab.isExecuting - { - if tab.resultSets.isEmpty { - Spacer() - } else { - ResultSuccessView( - rowsAffected: tab.rowsAffected, - executionTime: tab.executionTime, - statusMessage: tab.statusMessage - ) - } + case .json: + ResultsJsonView( + columns: tab.resultColumns, + columnTypes: tab.columnTypes, + rows: tab.resultRows, + selectedRowIndices: selectedRowIndices + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .data: + if let explainText = tab.explainText { + ExplainResultView(text: explainText, executionTime: tab.explainExecutionTime, plan: tab.explainPlan) + .frame(maxWidth: .infinity, maxHeight: .infinity) } else { - // Filter panel (collapsible, above data grid) - if filterStateManager.isVisible && tab.tabType == .table { - FilterPanelView( - filterState: filterStateManager, - columns: tab.resultColumns, - primaryKeyColumn: changeManager.primaryKeyColumn, - databaseType: connection.type, - onApply: onApplyFilters, - onUnset: onClearFilters + // Result tab bar (when multiple result sets) + if tab.resultSets.count > 1 { + resultTabBar(tab: tab) + Divider() + } + + // Inline error banner (when active result set has error) + if let error = tab.activeResultSet?.errorMessage { + InlineErrorBanner( + message: error, + onDismiss: { tab.activeResultSet?.errorMessage = nil } ) Divider() } - if tab.tabType == .query && !tab.resultColumns.isEmpty - && tab.resultRows.isEmpty && tab.lastExecutedAt != nil - && !tab.isExecuting && !filterStateManager.hasAppliedFilters + // Content: success view OR filter+grid + if let rs = tab.activeResultSet, rs.resultColumns.isEmpty, + rs.errorMessage == nil, tab.lastExecutedAt != nil, !tab.isExecuting + { + ResultSuccessView( + rowsAffected: rs.rowsAffected, + executionTime: rs.executionTime, + statusMessage: rs.statusMessage + ) + } else if tab.resultColumns.isEmpty && tab.errorMessage == nil + && tab.lastExecutedAt != nil && !tab.isExecuting { - emptyResultView(executionTime: tab.activeResultSet?.executionTime ?? tab.executionTime) + if tab.resultSets.isEmpty { + Spacer() + } else { + ResultSuccessView( + rowsAffected: tab.rowsAffected, + executionTime: tab.executionTime, + statusMessage: tab.statusMessage + ) + } } else { - dataGridView(tab: tab) + // Filter panel (collapsible, above data grid) + if filterStateManager.isVisible && tab.tabType == .table { + FilterPanelView( + filterState: filterStateManager, + columns: tab.resultColumns, + primaryKeyColumn: changeManager.primaryKeyColumn, + databaseType: connection.type, + onApply: onApplyFilters, + onUnset: onClearFilters + ) + Divider() + } + + if tab.tabType == .query && !tab.resultColumns.isEmpty + && tab.resultRows.isEmpty && tab.lastExecutedAt != nil + && !tab.isExecuting && !filterStateManager.hasAppliedFilters + { + emptyResultView(executionTime: tab.activeResultSet?.executionTime ?? tab.executionTime) + } else { + dataGridView(tab: tab) + } } } } @@ -768,7 +781,7 @@ struct MainEditorContentView: View { columnVisibilityManager: columnVisibilityManager, allColumns: tab.resultColumns, selectedRowIndices: selectedRowIndices, - showStructure: showStructureBinding(for: tab), + viewMode: resultsViewModeBinding(for: tab), onFirstPage: onFirstPage, onPreviousPage: onPreviousPage, onNextPage: onNextPage, @@ -781,13 +794,13 @@ struct MainEditorContentView: View { ) } - private func showStructureBinding(for tab: QueryTab) -> Binding { + private func resultsViewModeBinding(for tab: QueryTab) -> Binding { Binding( - get: { tab.showStructure }, + get: { tab.resultsViewMode }, set: { newValue in Task { @MainActor in if let index = tabManager.selectedTabIndex { - tabManager.tabs[index].showStructure = newValue + tabManager.tabs[index].resultsViewMode = newValue } } } diff --git a/TablePro/Views/Main/Child/MainStatusBarView.swift b/TablePro/Views/Main/Child/MainStatusBarView.swift index 3f2edd75c..680e402b9 100644 --- a/TablePro/Views/Main/Child/MainStatusBarView.swift +++ b/TablePro/Views/Main/Child/MainStatusBarView.swift @@ -14,7 +14,7 @@ struct MainStatusBarView: View { let columnVisibilityManager: ColumnVisibilityManager let allColumns: [String] let selectedRowIndices: Set - @Binding var showStructure: Bool + @Binding var viewMode: ResultsViewMode @State private var showColumnPopover = false @@ -33,16 +33,28 @@ struct MainStatusBarView: View { var body: some View { HStack { - // Left: Data/Structure toggle for table tabs - if let tab = tab, tab.tabType == .table, tab.tableName != nil { - Picker(String(localized: "View Mode"), selection: $showStructure) { - Label("Data", systemImage: "tablecells").tag(false) - Label("Structure", systemImage: "list.bullet.rectangle").tag(true) + // Left: View mode toggle + if let tab = tab { + if tab.tabType == .table, tab.tableName != nil { + Picker(String(localized: "View Mode"), selection: $viewMode) { + Label("Data", systemImage: "tablecells").tag(ResultsViewMode.data) + Label("Structure", systemImage: "list.bullet.rectangle").tag(ResultsViewMode.structure) + Label("JSON", systemImage: "curlybraces").tag(ResultsViewMode.json) + } + .labelsHidden() + .pickerStyle(.segmented) + .frame(width: 260) + .controlSize(.small) + } else if !tab.resultColumns.isEmpty { + Picker(String(localized: "View Mode"), selection: $viewMode) { + Label("Data", systemImage: "tablecells").tag(ResultsViewMode.data) + Label("JSON", systemImage: "curlybraces").tag(ResultsViewMode.json) + } + .labelsHidden() + .pickerStyle(.segmented) + .frame(width: 140) + .controlSize(.small) } - .labelsHidden() - .pickerStyle(.segmented) - .frame(width: 180) - .controlSize(.small) } Spacer() diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index ea2e9ce10..e57e16c21 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -42,7 +42,7 @@ extension MainContentCoordinator { current.tableName == tableName, current.databaseName == currentDatabase { if showStructure, let idx = tabManager.selectedTabIndex { - tabManager.tabs[idx].showStructure = true + tabManager.tabs[idx].resultsViewMode = .structure } return } @@ -204,7 +204,7 @@ extension MainContentCoordinator { ) previewCoordinator.filterStateManager.clearAll() if let tabIndex = previewCoordinator.tabManager.selectedTabIndex { - previewCoordinator.tabManager.tabs[tabIndex].showStructure = showStructure + previewCoordinator.tabManager.tabs[tabIndex].resultsViewMode = showStructure ? .structure : .data previewCoordinator.tabManager.tabs[tabIndex].pagination.reset() previewCoordinator.toolbarState.isTableTab = true } @@ -274,7 +274,7 @@ extension MainContentCoordinator { ) filterStateManager.clearAll() if let tabIndex = tabManager.selectedTabIndex { - tabManager.tabs[tabIndex].showStructure = showStructure + tabManager.tabs[tabIndex].resultsViewMode = showStructure ? .structure : .data tabManager.tabs[tabIndex].pagination.reset() toolbarState.isTableTab = true } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Refresh.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Refresh.swift index e93ffab08..60f6fb9c7 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Refresh.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Refresh.swift @@ -17,7 +17,7 @@ extension MainContentCoordinator { ) { // If showing structure view, let it handle refresh notifications if let tabIndex = tabManager.selectedTabIndex, - tabManager.tabs[tabIndex].showStructure { + tabManager.tabs[tabIndex].resultsViewMode == .structure { return } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SQLPreview.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SQLPreview.swift index 3ee1f2447..0d87943dc 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SQLPreview.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SQLPreview.swift @@ -16,7 +16,7 @@ extension MainContentCoordinator { pendingDeletes: Set, tableOperationOptions: [String: TableOperationOptions] ) { - if tabManager.selectedTab?.showStructure == true { + if tabManager.selectedTab?.resultsViewMode == .structure { // Structure view handles its own preview via direct call structureActions?.previewSQL?() } else { diff --git a/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift b/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift index 3469070ac..d8af22d3d 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift @@ -81,15 +81,15 @@ extension MainContentView { ) } - // MARK: - Show Structure Binding + // MARK: - Results View Mode Binding - /// Binding for showStructure state - var showStructureBinding: Binding { + /// Binding for resultsViewMode state + var resultsViewModeBinding: Binding { Binding( - get: { coordinator.tabManager.selectedTab?.showStructure ?? false }, + get: { coordinator.tabManager.selectedTab?.resultsViewMode ?? .data }, set: { newValue in if let index = coordinator.tabManager.selectedTabIndex { - coordinator.tabManager.tabs[index].showStructure = newValue + coordinator.tabManager.tabs[index].resultsViewMode = newValue } } ) diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 8d789ae42..675035e47 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -234,7 +234,7 @@ final class MainContentCommandActions { } func copySelectedRows() { - if coordinator?.tabManager.selectedTab?.showStructure == true { + if coordinator?.tabManager.selectedTab?.resultsViewMode == .structure { coordinator?.structureActions?.copyRows?() } else { let indices = selectedRowIndices.wrappedValue @@ -253,7 +253,7 @@ final class MainContentCommandActions { } func pasteRows() { - if coordinator?.tabManager.selectedTab?.showStructure == true { + if coordinator?.tabManager.selectedTab?.resultsViewMode == .structure { coordinator?.structureActions?.pasteRows?() } else { var indices = selectedRowIndices.wrappedValue @@ -412,7 +412,7 @@ final class MainContentCommandActions { } // Structure view saves via direct coordinator call - if coordinator.tabManager.selectedTab?.showStructure == true { + if coordinator.tabManager.selectedTab?.resultsViewMode == .structure { coordinator.structureActions?.saveChanges?() performClose() return @@ -532,7 +532,7 @@ final class MainContentCommandActions { func saveChanges() { // Check if we're in structure view mode - if coordinator?.tabManager.selectedTab?.showStructure == true { + if coordinator?.tabManager.selectedTab?.resultsViewMode == .structure { coordinator?.structureActions?.saveChanges?() } else if coordinator?.changeManager.hasChanges == true || !pendingTruncates.wrappedValue.isEmpty @@ -733,7 +733,7 @@ final class MainContentCommandActions { // MARK: - Undo/Redo (Group A — Called Directly) func undoChange() { - if coordinator?.tabManager.selectedTab?.showStructure == true { + if coordinator?.tabManager.selectedTab?.resultsViewMode == .structure { coordinator?.structureActions?.undo?() } else { var indices = selectedRowIndices.wrappedValue @@ -743,7 +743,7 @@ final class MainContentCommandActions { } func redoChange() { - if coordinator?.tabManager.selectedTab?.showStructure == true { + if coordinator?.tabManager.selectedTab?.resultsViewMode == .structure { coordinator?.structureActions?.redo?() } else { coordinator?.redoLastChange() diff --git a/TablePro/Views/Results/ResultsJsonView.swift b/TablePro/Views/Results/ResultsJsonView.swift new file mode 100644 index 000000000..0f60d34c0 --- /dev/null +++ b/TablePro/Views/Results/ResultsJsonView.swift @@ -0,0 +1,73 @@ +// +// ResultsJsonView.swift +// TablePro +// + +import SwiftUI + +internal struct ResultsJsonView: View { + let columns: [String] + let columnTypes: [ColumnType] + let rows: [[String?]] + let selectedRowIndices: Set + + private var displayRows: [[String?]] { + if selectedRowIndices.isEmpty { + return rows + } + return selectedRowIndices.sorted().compactMap { idx in + rows.indices.contains(idx) ? rows[idx] : nil + } + } + + private var jsonString: String { + let converter = JsonRowConverter(columns: columns, columnTypes: columnTypes) + return converter.generateJson(rows: displayRows) + } + + private var rowCountText: String { + let displaying = displayRows.count + let total = rows.count + if selectedRowIndices.isEmpty || displaying == total { + return String(format: String(localized: "%d rows"), total) + } + return String(format: String(localized: "%d of %d rows"), displaying, total) + } + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 8) { + Text(rowCountText) + .font(.caption) + .foregroundStyle(.secondary) + + Spacer() + + Button { + ClipboardService.shared.writeText(jsonString) + } label: { + Label("Copy JSON", systemImage: "doc.on.doc") + } + .buttonStyle(.borderless) + .controlSize(.small) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + + Divider() + + if rows.isEmpty { + ContentUnavailableView( + String(localized: "No Data"), + systemImage: "curlybraces", + description: Text(String(localized: "Execute a query to view results as JSON")) + ) + } else { + JSONViewerView( + text: .constant(jsonString), + isEditable: false + ) + } + } + } +} From 1c1deb83e55d7af3f4d82bd08acdf9f954f555f8 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 23:56:17 +0700 Subject: [PATCH 2/3] fix: use 8pt horizontal padding in status bar to match native macOS --- TablePro/Views/Main/Child/MainStatusBarView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TablePro/Views/Main/Child/MainStatusBarView.swift b/TablePro/Views/Main/Child/MainStatusBarView.swift index 680e402b9..2a72f6cc2 100644 --- a/TablePro/Views/Main/Child/MainStatusBarView.swift +++ b/TablePro/Views/Main/Child/MainStatusBarView.swift @@ -178,7 +178,7 @@ struct MainStatusBarView: View { } } } - .padding(.horizontal, 12) + .padding(.horizontal, 8) .padding(.vertical, 4) .background(Color(nsColor: .controlBackgroundColor)) .onChange(of: tab?.id) { _, _ in From 84e02a81fdf15ba4918d57c97c0ed332064d2a0d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 23:58:37 +0700 Subject: [PATCH 3/3] fix: remove horizontal padding from status bar --- TablePro/Views/Main/Child/MainStatusBarView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TablePro/Views/Main/Child/MainStatusBarView.swift b/TablePro/Views/Main/Child/MainStatusBarView.swift index 2a72f6cc2..60c6ff3da 100644 --- a/TablePro/Views/Main/Child/MainStatusBarView.swift +++ b/TablePro/Views/Main/Child/MainStatusBarView.swift @@ -178,7 +178,7 @@ struct MainStatusBarView: View { } } } - .padding(.horizontal, 8) + .padding(.horizontal, 0) .padding(.vertical, 4) .background(Color(nsColor: .controlBackgroundColor)) .onChange(of: tab?.id) { _, _ in