From 60f0d95ccb5b0770666aa2d3fa95c51fc8b3a1f8 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: Mon, 30 Mar 2026 16:34:18 +0700 Subject: [PATCH 01/33] =?UTF-8?q?refactor:=20Phase=200+1=20=E2=80=94=20Res?= =?UTF-8?q?ultSet=20model=20+=20DataGridView=20split?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0: Add ResultSet @Observable class and extend QueryTab with resultSets array, activeResultSetId, isResultsCollapsed. Phase 1: Extract TableViewCoordinator from DataGridView.swift into DataGridCoordinator.swift. DataGridView.swift now contains only the NSViewRepresentable shell + helper types. Zero behavior change. --- TablePro/Models/Query/QueryTab.swift | 13 + TablePro/Models/Query/ResultSet.swift | 46 +++ .../Views/Results/DataGridCoordinator.swift | 350 ++++++++++++++++++ TablePro/Views/Results/DataGridView.swift | 340 ----------------- 4 files changed, 409 insertions(+), 340 deletions(-) create mode 100644 TablePro/Models/Query/ResultSet.swift create mode 100644 TablePro/Views/Results/DataGridCoordinator.swift diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index a0caa05e..260a2573 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -369,6 +369,16 @@ struct QueryTab: Identifiable, Equatable { // Whether this tab is a preview (temporary) tab that gets replaced on next navigation var isPreview: Bool + // Multi-result-set support (Phase 0: added alongside existing single-result properties) + var resultSets: [ResultSet] = [] + var activeResultSetId: UUID? + var isResultsCollapsed: Bool = false + + var activeResultSet: ResultSet? { + guard let id = activeResultSetId else { return resultSets.last } + return resultSets.first { $0.id == id } + } + // Source file URL for .sql files opened from disk (used for deduplication) var sourceFileURL: URL? @@ -542,6 +552,9 @@ struct QueryTab: Identifiable, Equatable { && lhs.rowsAffected == rhs.rowsAffected && lhs.isPreview == rhs.isPreview && lhs.hasUserInteraction == rhs.hasUserInteraction + && lhs.isResultsCollapsed == rhs.isResultsCollapsed + && lhs.resultSets.map(\.id) == rhs.resultSets.map(\.id) + && lhs.activeResultSetId == rhs.activeResultSetId } } diff --git a/TablePro/Models/Query/ResultSet.swift b/TablePro/Models/Query/ResultSet.swift new file mode 100644 index 00000000..61e7450a --- /dev/null +++ b/TablePro/Models/Query/ResultSet.swift @@ -0,0 +1,46 @@ +// +// ResultSet.swift +// TablePro +// +// A single result set from one SQL statement execution. +// + +import Foundation +import Observation +import os + +@MainActor +@Observable +final class ResultSet: Identifiable { + let id: UUID + var label: String + var rowBuffer: RowBuffer + var executionTime: TimeInterval? + var rowsAffected: Int = 0 + var errorMessage: String? + var statusMessage: String? + var tableName: String? + var isEditable: Bool = false + var isPinned: Bool = false + var resultVersion: Int = 0 + var metadataVersion: Int = 0 + var sortState = SortState() + var pagination = PaginationState() + var columnLayout = ColumnLayoutState() + + // Column metadata + var columnTypes: [ColumnType] = [] + var columnDefaults: [String: String?] = [:] + var columnForeignKeys: [String: ForeignKeyInfo] = [:] + var columnEnumValues: [String: [String]] = [:] + var columnNullable: [String: Bool] = [:] + + var resultColumns: [String] { rowBuffer.columns } + var resultRows: [[String?]] { rowBuffer.rows } + + init(id: UUID = UUID(), label: String, rowBuffer: RowBuffer = RowBuffer()) { + self.id = id + self.label = label + self.rowBuffer = rowBuffer + } +} diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift new file mode 100644 index 00000000..827b159d --- /dev/null +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -0,0 +1,350 @@ +// +// DataGridCoordinator.swift +// TablePro +// +// Coordinator handling NSTableView delegate and data source for DataGridView. +// + +import AppKit +import SwiftUI + +// MARK: - Coordinator + +/// Coordinator handling NSTableView delegate and data source +@MainActor +final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewDataSource, + NSControlTextEditingDelegate, NSTextFieldDelegate, NSMenuDelegate +{ + var rowProvider: InMemoryRowProvider + var changeManager: AnyChangeManager + var isEditable: Bool + var onRefresh: (() -> Void)? + var onCellEdit: ((Int, Int, String?) -> Void)? + var onDeleteRows: ((Set) -> Void)? + var onCopyRows: ((Set) -> Void)? + var onPasteRows: (() -> Void)? + var onUndo: (() -> Void)? + var onRedo: (() -> Void)? + var onSort: ((Int, Bool, Bool) -> Void)? + var onAddRow: (() -> Void)? + var onUndoInsert: ((Int) -> Void)? + var onFilterColumn: ((String) -> Void)? + var onHideColumn: ((String) -> Void)? + var onMoveRow: ((Int, Int) -> Void)? + var onNavigateFK: ((String, ForeignKeyInfo) -> Void)? + var getVisualState: ((Int) -> RowVisualState)? + var dropdownColumns: Set? + var typePickerColumns: Set? + var connectionId: UUID? + var databaseType: DatabaseType? + var tableName: String? + var primaryKeyColumn: String? + var tabType: TabType? + + /// Check if undo is available + func canUndo() -> Bool { + changeManager.hasChanges + } + + /// Check if redo is available + func canRedo() -> Bool { + changeManager.canRedo + } + + /// Capture current column widths and order from the live NSTableView + /// and persist directly to ColumnLayoutStorage. Called from dismantleNSView + /// to guarantee layout is saved even when the view is torn down without + /// a SwiftUI render cycle (e.g., closing a tab). + func persistColumnLayoutToStorage() { + guard tabType == .table else { return } + guard let tableView, let connectionId, let tableName, !tableName.isEmpty else { return } + guard !rowProvider.columns.isEmpty else { return } + + var widths: [String: CGFloat] = [:] + var order: [String] = [] + for column in tableView.tableColumns where column.identifier.rawValue != "__rowNumber__" { + guard let colIndex = DataGridView.columnIndex(from: column.identifier), + colIndex < rowProvider.columns.count else { continue } + let name = rowProvider.columns[colIndex] + widths[name] = column.width + order.append(name) + } + + guard !widths.isEmpty else { return } + var layout = ColumnLayoutState() + layout.columnWidths = widths + layout.columnOrder = order + ColumnLayoutStorage.shared.save(layout, for: tableName, connectionId: connectionId) + } + + weak var tableView: NSTableView? + let cellFactory = DataGridCellFactory() + var overlayEditor: CellOverlayEditor? + + // Settings observer for real-time updates + var settingsObserver: NSObjectProtocol? + // Theme observer for font/color changes + var themeObserver: NSObjectProtocol? + /// Snapshot of last-seen data grid settings for change detection + private var lastDataGridSettings: DataGridSettings + + @Binding var selectedRowIndices: Set + + var lastIdentity: DataGridIdentity? + var lastReloadVersion: Int = 0 + var lastReapplyVersion: Int = -1 + private(set) var cachedRowCount: Int = 0 + private(set) var cachedColumnCount: Int = 0 + var isSyncingSortDescriptors: Bool = false + /// Suppresses selection delegate callbacks during programmatic selection sync + var isSyncingSelection = false + var isRebuildingColumns: Bool = false + var hasUserResizedColumns: Bool = false + /// Guards against two-frame bounce when async column layout write-back triggers updateNSView + var isWritingColumnLayout: Bool = false + /// Debounced work item for persisting column layout after resize/reorder + var layoutPersistWorkItem: DispatchWorkItem? + + private let cellIdentifier = NSUserInterfaceItemIdentifier("DataCell") + static let rowViewIdentifier = NSUserInterfaceItemIdentifier("TableRowView") + internal var pendingDropdownRow: Int = 0 + internal var pendingDropdownColumn: Int = 0 + private var rowVisualStateCache: [Int: RowVisualState] = [:] + private var lastVisualStateCacheVersion: Int = 0 + private let largeDatasetThreshold = 5_000 + + var isLargeDataset: Bool { cachedRowCount > largeDatasetThreshold } + + init( + rowProvider: InMemoryRowProvider, + changeManager: AnyChangeManager, + isEditable: Bool, + selectedRowIndices: Binding>, + onRefresh: (() -> Void)?, + onCellEdit: ((Int, Int, String?) -> Void)?, + onDeleteRows: ((Set) -> Void)?, + onCopyRows: ((Set) -> Void)?, + onPasteRows: (() -> Void)?, + onUndo: (() -> Void)?, + onRedo: (() -> Void)? + ) { + self.rowProvider = rowProvider + self.changeManager = changeManager + self.isEditable = isEditable + self._selectedRowIndices = selectedRowIndices + self.onRefresh = onRefresh + self.onCellEdit = onCellEdit + self.onDeleteRows = onDeleteRows + self.onCopyRows = onCopyRows + self.onPasteRows = onPasteRows + self.onUndo = onUndo + self.onRedo = onRedo + self.lastDataGridSettings = AppSettingsManager.shared.dataGrid + super.init() + updateCache() + + // Subscribe to theme changes for font/color updates + observeThemeChanges() + + // Subscribe to settings changes for real-time updates + settingsObserver = NotificationCenter.default.addObserver( + forName: .dataGridSettingsDidChange, + object: nil, + queue: .main + ) { [weak self] _ in + guard let self else { return } + + DispatchQueue.main.async { [weak self] in + guard let self, let tableView = self.tableView else { return } + let settings = AppSettingsManager.shared.dataGrid + let prev = self.lastDataGridSettings + self.lastDataGridSettings = settings + + let newRowHeight = CGFloat(settings.rowHeight.rawValue) + if tableView.rowHeight != newRowHeight { + tableView.rowHeight = newRowHeight + tableView.tile() + } + + // Font changes are handled by .themeDidChange observer. + // Check for data format changes that need cell re-rendering. + let dataChanged = prev.dateFormat != settings.dateFormat + || prev.nullDisplay != settings.nullDisplay + + if dataChanged { + self.rowProvider.invalidateDisplayCache() + let visibleRect = tableView.visibleRect + let visibleRange = tableView.rows(in: visibleRect) + if visibleRange.length > 0 { + tableView.reloadData( + forRowIndexes: IndexSet(integersIn: visibleRange.location..<(visibleRange.location + visibleRange.length)), + columnIndexes: IndexSet(integersIn: 0.. 0 else { return } + + let columnCount = tableView.numberOfColumns + for row in visibleRange.location..<(visibleRange.location + visibleRange.length) { + for col in 0.. = rowChange.type == .update + ? Set(rowChange.cellChanges.map { $0.columnIndex }) + : [] + + rowVisualStateCache[rowIndex] = RowVisualState( + isDeleted: isDeleted, + isInserted: isInserted, + modifiedColumns: modifiedColumns + ) + } + } + + func visualState(for row: Int) -> RowVisualState { + // If custom callback provided, use it + if let callback = getVisualState { + return callback(row) + } + // Otherwise use cache + return rowVisualStateCache[row] ?? .empty + } + + // MARK: - NSTableViewDataSource + + func numberOfRows(in tableView: NSTableView) -> Int { + cachedRowCount + } +} diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 3a87fa46..ad28f3dc 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -679,346 +679,6 @@ struct DataGridView: NSViewRepresentable { } } -// MARK: - Coordinator - -/// Coordinator handling NSTableView delegate and data source -@MainActor -final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewDataSource, - NSControlTextEditingDelegate, NSTextFieldDelegate, NSMenuDelegate -{ - var rowProvider: InMemoryRowProvider - var changeManager: AnyChangeManager - var isEditable: Bool - var onRefresh: (() -> Void)? - var onCellEdit: ((Int, Int, String?) -> Void)? - var onDeleteRows: ((Set) -> Void)? - var onCopyRows: ((Set) -> Void)? - var onPasteRows: (() -> Void)? - var onUndo: (() -> Void)? - var onRedo: (() -> Void)? - var onSort: ((Int, Bool, Bool) -> Void)? - var onAddRow: (() -> Void)? - var onUndoInsert: ((Int) -> Void)? - var onFilterColumn: ((String) -> Void)? - var onHideColumn: ((String) -> Void)? - var onMoveRow: ((Int, Int) -> Void)? - var onNavigateFK: ((String, ForeignKeyInfo) -> Void)? - var getVisualState: ((Int) -> RowVisualState)? - var dropdownColumns: Set? - var typePickerColumns: Set? - var connectionId: UUID? - var databaseType: DatabaseType? - var tableName: String? - var primaryKeyColumn: String? - var tabType: TabType? - - /// Check if undo is available - func canUndo() -> Bool { - changeManager.hasChanges - } - - /// Check if redo is available - func canRedo() -> Bool { - changeManager.canRedo - } - - /// Capture current column widths and order from the live NSTableView - /// and persist directly to ColumnLayoutStorage. Called from dismantleNSView - /// to guarantee layout is saved even when the view is torn down without - /// a SwiftUI render cycle (e.g., closing a tab). - func persistColumnLayoutToStorage() { - guard tabType == .table else { return } - guard let tableView, let connectionId, let tableName, !tableName.isEmpty else { return } - guard !rowProvider.columns.isEmpty else { return } - - var widths: [String: CGFloat] = [:] - var order: [String] = [] - for column in tableView.tableColumns where column.identifier.rawValue != "__rowNumber__" { - guard let colIndex = DataGridView.columnIndex(from: column.identifier), - colIndex < rowProvider.columns.count else { continue } - let name = rowProvider.columns[colIndex] - widths[name] = column.width - order.append(name) - } - - guard !widths.isEmpty else { return } - var layout = ColumnLayoutState() - layout.columnWidths = widths - layout.columnOrder = order - ColumnLayoutStorage.shared.save(layout, for: tableName, connectionId: connectionId) - } - - weak var tableView: NSTableView? - let cellFactory = DataGridCellFactory() - var overlayEditor: CellOverlayEditor? - - // Settings observer for real-time updates - fileprivate var settingsObserver: NSObjectProtocol? - // Theme observer for font/color changes - fileprivate var themeObserver: NSObjectProtocol? - /// Snapshot of last-seen data grid settings for change detection - private var lastDataGridSettings: DataGridSettings - - @Binding var selectedRowIndices: Set - - fileprivate var lastIdentity: DataGridIdentity? - var lastReloadVersion: Int = 0 - var lastReapplyVersion: Int = -1 - private(set) var cachedRowCount: Int = 0 - private(set) var cachedColumnCount: Int = 0 - var isSyncingSortDescriptors: Bool = false - /// Suppresses selection delegate callbacks during programmatic selection sync - var isSyncingSelection = false - var isRebuildingColumns: Bool = false - var hasUserResizedColumns: Bool = false - /// Guards against two-frame bounce when async column layout write-back triggers updateNSView - var isWritingColumnLayout: Bool = false - /// Debounced work item for persisting column layout after resize/reorder - var layoutPersistWorkItem: DispatchWorkItem? - - private let cellIdentifier = NSUserInterfaceItemIdentifier("DataCell") - static let rowViewIdentifier = NSUserInterfaceItemIdentifier("TableRowView") - internal var pendingDropdownRow: Int = 0 - internal var pendingDropdownColumn: Int = 0 - private var rowVisualStateCache: [Int: RowVisualState] = [:] - private var lastVisualStateCacheVersion: Int = 0 - private let largeDatasetThreshold = 5_000 - - var isLargeDataset: Bool { cachedRowCount > largeDatasetThreshold } - - init( - rowProvider: InMemoryRowProvider, - changeManager: AnyChangeManager, - isEditable: Bool, - selectedRowIndices: Binding>, - onRefresh: (() -> Void)?, - onCellEdit: ((Int, Int, String?) -> Void)?, - onDeleteRows: ((Set) -> Void)?, - onCopyRows: ((Set) -> Void)?, - onPasteRows: (() -> Void)?, - onUndo: (() -> Void)?, - onRedo: (() -> Void)? - ) { - self.rowProvider = rowProvider - self.changeManager = changeManager - self.isEditable = isEditable - self._selectedRowIndices = selectedRowIndices - self.onRefresh = onRefresh - self.onCellEdit = onCellEdit - self.onDeleteRows = onDeleteRows - self.onCopyRows = onCopyRows - self.onPasteRows = onPasteRows - self.onUndo = onUndo - self.onRedo = onRedo - self.lastDataGridSettings = AppSettingsManager.shared.dataGrid - super.init() - updateCache() - - // Subscribe to theme changes for font/color updates - observeThemeChanges() - - // Subscribe to settings changes for real-time updates - settingsObserver = NotificationCenter.default.addObserver( - forName: .dataGridSettingsDidChange, - object: nil, - queue: .main - ) { [weak self] _ in - guard let self else { return } - - DispatchQueue.main.async { [weak self] in - guard let self, let tableView = self.tableView else { return } - let settings = AppSettingsManager.shared.dataGrid - let prev = self.lastDataGridSettings - self.lastDataGridSettings = settings - - let newRowHeight = CGFloat(settings.rowHeight.rawValue) - if tableView.rowHeight != newRowHeight { - tableView.rowHeight = newRowHeight - tableView.tile() - } - - // Font changes are handled by .themeDidChange observer. - // Check for data format changes that need cell re-rendering. - let dataChanged = prev.dateFormat != settings.dateFormat - || prev.nullDisplay != settings.nullDisplay - - if dataChanged { - self.rowProvider.invalidateDisplayCache() - let visibleRect = tableView.visibleRect - let visibleRange = tableView.rows(in: visibleRect) - if visibleRange.length > 0 { - tableView.reloadData( - forRowIndexes: IndexSet(integersIn: visibleRange.location..<(visibleRange.location + visibleRange.length)), - columnIndexes: IndexSet(integersIn: 0.. 0 else { return } - - let columnCount = tableView.numberOfColumns - for row in visibleRange.location..<(visibleRange.location + visibleRange.length) { - for col in 0.. = rowChange.type == .update - ? Set(rowChange.cellChanges.map { $0.columnIndex }) - : [] - - rowVisualStateCache[rowIndex] = RowVisualState( - isDeleted: isDeleted, - isInserted: isInserted, - modifiedColumns: modifiedColumns - ) - } - } - - func visualState(for row: Int) -> RowVisualState { - // If custom callback provided, use it - if let callback = getVisualState { - return callback(row) - } - // Otherwise use cache - return rowVisualStateCache[row] ?? .empty - } - - // MARK: - NSTableViewDataSource - - func numberOfRows(in tableView: NSTableView) -> Int { - cachedRowCount - } -} // MARK: - Preview From e1d45b615ee330a41e56c24436268b76798b4d4d 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: Mon, 30 Mar 2026 16:39:09 +0700 Subject: [PATCH 02/33] feat: add keyboard shortcuts for results panel toggle and tab navigation - Cmd+Opt+R: Toggle Results panel collapse/expand - Cmd+[: Previous Result tab - Cmd+]: Next Result tab - View menu items + command actions wired - Keyboard shortcuts docs updated --- .../Models/UI/KeyboardShortcutModels.swift | 12 ++++++++- TablePro/TableProApp.swift | 20 +++++++++++++++ .../Main/MainContentCommandActions.swift | 25 +++++++++++++++++++ docs/features/keyboard-shortcuts.mdx | 8 ++++++ 4 files changed, 64 insertions(+), 1 deletion(-) diff --git a/TablePro/Models/UI/KeyboardShortcutModels.swift b/TablePro/Models/UI/KeyboardShortcutModels.swift index 208dfa77..2df686b0 100644 --- a/TablePro/Models/UI/KeyboardShortcutModels.swift +++ b/TablePro/Models/UI/KeyboardShortcutModels.swift @@ -71,6 +71,9 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case toggleInspector case toggleFilters case toggleHistory + case toggleResults + case previousResultTab + case nextResultTab // Tabs case showPreviousTabBrackets @@ -94,7 +97,8 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { .delete, .selectAll, .clearSelection, .addRow, .duplicateRow, .truncateTable: return .edit - case .toggleTableBrowser, .toggleInspector, .toggleFilters, .toggleHistory: + case .toggleTableBrowser, .toggleInspector, .toggleFilters, .toggleHistory, + .toggleResults, .previousResultTab, .nextResultTab: return .view case .showPreviousTabBrackets, .showNextTabBrackets, .previousTabArrows, .nextTabArrows: @@ -137,6 +141,9 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case .toggleInspector: return String(localized: "Toggle Inspector") case .toggleFilters: return String(localized: "Toggle Filters") case .toggleHistory: return String(localized: "Toggle History") + case .toggleResults: return String(localized: "Toggle Results") + case .previousResultTab: return String(localized: "Previous Result") + case .nextResultTab: return String(localized: "Next Result") case .showPreviousTabBrackets: return String(localized: "Show Previous Tab") case .showNextTabBrackets: return String(localized: "Show Next Tab") case .previousTabArrows: return String(localized: "Previous Tab (Alt)") @@ -440,6 +447,9 @@ struct KeyboardSettings: Codable, Equatable { .toggleInspector: KeyCombo(key: "b", command: true, shift: true), .toggleFilters: KeyCombo(key: "f", command: true), .toggleHistory: KeyCombo(key: "y", command: true), + .toggleResults: KeyCombo(key: "r", command: true, option: true), + .previousResultTab: KeyCombo(key: "[", command: true), + .nextResultTab: KeyCombo(key: "]", command: true), // Tabs .showPreviousTabBrackets: KeyCombo(key: "[", command: true, shift: true), diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index 3a7ba328..cd0f9886 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -367,6 +367,26 @@ struct AppMenuCommands: Commands { } .optionalKeyboardShortcut(shortcut(for: .toggleHistory)) .disabled(!appState.isConnected) + + Divider() + + Button("Toggle Results") { + actions?.toggleResults() + } + .optionalKeyboardShortcut(shortcut(for: .toggleResults)) + .disabled(!appState.isConnected) + + Button("Previous Result") { + actions?.previousResultTab() + } + .optionalKeyboardShortcut(shortcut(for: .previousResultTab)) + .disabled(!appState.isConnected) + + Button("Next Result") { + actions?.nextResultTab() + } + .optionalKeyboardShortcut(shortcut(for: .nextResultTab)) + .disabled(!appState.isConnected) } // Tab navigation shortcuts — native macOS window tabs diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 288b1d8c..f44e1ab9 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -538,6 +538,31 @@ final class MainContentCommandActions { rightPanelState.isPresented.toggle() } + func toggleResults() { + guard let coordinator, let tabIndex = coordinator.tabManager.selectedTabIndex else { return } + coordinator.tabManager.tabs[tabIndex].isResultsCollapsed.toggle() + } + + func previousResultTab() { + guard let coordinator, let tabIndex = coordinator.tabManager.selectedTabIndex else { return } + let tab = coordinator.tabManager.tabs[tabIndex] + guard tab.resultSets.count > 1, + let currentId = tab.activeResultSetId ?? tab.resultSets.last?.id, + let currentIndex = tab.resultSets.firstIndex(where: { $0.id == currentId }), + currentIndex > 0 else { return } + coordinator.tabManager.tabs[tabIndex].activeResultSetId = tab.resultSets[currentIndex - 1].id + } + + func nextResultTab() { + guard let coordinator, let tabIndex = coordinator.tabManager.selectedTabIndex else { return } + let tab = coordinator.tabManager.tabs[tabIndex] + guard tab.resultSets.count > 1, + let currentId = tab.activeResultSetId ?? tab.resultSets.last?.id, + let currentIndex = tab.resultSets.firstIndex(where: { $0.id == currentId }), + currentIndex < tab.resultSets.count - 1 else { return } + coordinator.tabManager.tabs[tabIndex].activeResultSetId = tab.resultSets[currentIndex + 1].id + } + // MARK: - Database Operations (Group A — Called Directly) func openDatabaseSwitcher() { diff --git a/docs/features/keyboard-shortcuts.mdx b/docs/features/keyboard-shortcuts.mdx index 99c627df..3ae641e1 100644 --- a/docs/features/keyboard-shortcuts.mdx +++ b/docs/features/keyboard-shortcuts.mdx @@ -178,9 +178,17 @@ TablePro is keyboard-driven. Most actions have shortcuts, and most menu shortcut |--------|----------| | Query History | `Cmd+Y` | | Toggle Cell Inspector | `Cmd+Shift+B` | +| Toggle Results | `Cmd+Opt+R` | | Toggle AI Chat | `Cmd+Shift+L` | | Settings | `Cmd+,` | +### Results + +| Action | Shortcut | +|--------|----------| +| Previous Result | `Cmd+[` | +| Next Result | `Cmd+]` | + ### AI | Action | Shortcut | Description | From 86604558f41dd99eefdb5e1961a7a1f0c208f63a 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: Mon, 30 Mar 2026 16:42:53 +0700 Subject: [PATCH 03/33] feat: add ResultsPanelView, ResultTabBar, InlineErrorBanner, ResultSuccessView - ResultsPanelView: orchestrates result tab bar, error banner, content area - ResultTabBar: horizontal scrollable tabs with pin/close/close-others - InlineErrorBanner: red dismissable error banner with optional AI fix button - ResultSuccessView: compact DDL/DML success (replaces full-screen QuerySuccessView) --- .../Views/Results/InlineErrorBanner.swift | 56 +++++++++ .../Views/Results/ResultSuccessView.swift | 47 ++++++++ TablePro/Views/Results/ResultTabBar.swift | 83 ++++++++++++++ TablePro/Views/Results/ResultsPanelView.swift | 107 ++++++++++++++++++ 4 files changed, 293 insertions(+) create mode 100644 TablePro/Views/Results/InlineErrorBanner.swift create mode 100644 TablePro/Views/Results/ResultSuccessView.swift create mode 100644 TablePro/Views/Results/ResultTabBar.swift create mode 100644 TablePro/Views/Results/ResultsPanelView.swift diff --git a/TablePro/Views/Results/InlineErrorBanner.swift b/TablePro/Views/Results/InlineErrorBanner.swift new file mode 100644 index 00000000..bf19e141 --- /dev/null +++ b/TablePro/Views/Results/InlineErrorBanner.swift @@ -0,0 +1,56 @@ +// +// InlineErrorBanner.swift +// TablePro +// +// Dismissable red error banner for query errors, displayed inline above results. +// + +import SwiftUI + +struct InlineErrorBanner: View { + let message: String + var onDismiss: (() -> Void)? + var onAIFix: (() -> Void)? + + var body: some View { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + Text(message) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) + .lineLimit(3) + .textSelection(.enabled) + Spacer() + if let onAIFix { + Button("Fix with AI") { onAIFix() } + .buttonStyle(.bordered) + .controlSize(.small) + } + if let onDismiss { + Button { onDismiss() } label: { + Image(systemName: "xmark") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.red.opacity(0.08)) + } +} + +#Preview { + VStack { + InlineErrorBanner( + message: "ERROR 1064 (42000): You have an error in your SQL syntax", + onDismiss: {}, + onAIFix: {} + ) + InlineErrorBanner( + message: "Connection refused", + onDismiss: {} + ) + } + .frame(width: 600) +} diff --git a/TablePro/Views/Results/ResultSuccessView.swift b/TablePro/Views/Results/ResultSuccessView.swift new file mode 100644 index 00000000..0a6bb2a2 --- /dev/null +++ b/TablePro/Views/Results/ResultSuccessView.swift @@ -0,0 +1,47 @@ +// +// ResultSuccessView.swift +// TablePro +// +// Compact DDL/DML success view for the results panel. +// Replaces the full-screen QuerySuccessView for multi-result contexts. +// + +import SwiftUI + +struct ResultSuccessView: View { + let rowsAffected: Int + let executionTime: TimeInterval? + let statusMessage: String? + + var body: some View { + VStack(spacing: 16) { + Spacer() + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 36)) + .foregroundStyle(.green) + Text("\(rowsAffected) row(s) affected") + .font(.system(size: ThemeEngine.shared.activeTheme.typography.body)) + if let time = executionTime { + Text(String(format: "%.3fs", time)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) + .foregroundStyle(.secondary) + } + if let status = statusMessage, !status.isEmpty { + Text(status) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) + .foregroundStyle(.secondary) + } + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +#Preview { + ResultSuccessView( + rowsAffected: 5, + executionTime: 0.042, + statusMessage: "Processed: 1.5 GB" + ) + .frame(width: 400, height: 300) +} diff --git a/TablePro/Views/Results/ResultTabBar.swift b/TablePro/Views/Results/ResultTabBar.swift new file mode 100644 index 00000000..d806cfe9 --- /dev/null +++ b/TablePro/Views/Results/ResultTabBar.swift @@ -0,0 +1,83 @@ +// +// ResultTabBar.swift +// TablePro +// +// Horizontal tab bar for switching between multiple result sets. +// Only shown when a query produces 2+ result sets. +// + +import SwiftUI + +struct ResultTabBar: View { + let resultSets: [ResultSet] + @Binding var activeResultSetId: UUID? + var onClose: ((UUID) -> Void)? + var onPin: ((UUID) -> Void)? + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 0) { + ForEach(resultSets) { rs in + resultTab(rs) + } + } + } + .frame(height: 28) + .background(Color(nsColor: .controlBackgroundColor)) + } + + private func resultTab(_ rs: ResultSet) -> some View { + let isActive = rs.id == (activeResultSetId ?? resultSets.last?.id) + return HStack(spacing: 4) { + if rs.isPinned { + Image(systemName: "pin.fill") + .font(.system(size: 8)) + .foregroundStyle(.secondary) + } + Text(rs.label) + .font(.system(size: 11)) + .lineLimit(1) + if !rs.isPinned { + Button { onClose?(rs.id) } label: { + Image(systemName: "xmark") + .font(.system(size: 8)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(isActive ? Color(nsColor: .selectedContentBackgroundColor).opacity(0.3) : Color.clear) + .cornerRadius(4) + .contentShape(Rectangle()) + .onTapGesture { activeResultSetId = rs.id } + .contextMenu { + Button(rs.isPinned ? "Unpin" : "Pin Result") { onPin?(rs.id) } + Divider() + Button("Close") { onClose?(rs.id) } + .disabled(rs.isPinned) + Button("Close Others") { + for other in resultSets where other.id != rs.id && !other.isPinned { + onClose?(other.id) + } + } + } + } +} + +#Preview { + @Previewable @State var activeId: UUID? + let sets = [ + ResultSet(label: "Result 1", rowsAffected: 10), + ResultSet(label: "Result 2", rowsAffected: 5), + ResultSet(label: "Result 3", isPinned: true) + ] + ResultTabBar( + resultSets: sets, + activeResultSetId: $activeId, + onClose: { _ in }, + onPin: { _ in } + ) + .frame(width: 500) +} diff --git a/TablePro/Views/Results/ResultsPanelView.swift b/TablePro/Views/Results/ResultsPanelView.swift new file mode 100644 index 00000000..d796be47 --- /dev/null +++ b/TablePro/Views/Results/ResultsPanelView.swift @@ -0,0 +1,107 @@ +// +// ResultsPanelView.swift +// TablePro +// +// Main container that orchestrates result tab bar, error banners, and result content. +// Will replace resultsSection() in MainEditorContentView in Phase 3. +// + +import SwiftUI + +struct ResultsPanelView: View { + let tab: QueryTab + let connection: DatabaseConnection + var coordinator: MainContentCoordinator? + + // Callbacks matching DataGridView expectations + var onCellEdit: ((Int, Int, String?) -> Void)? + var onDeleteRows: ((Set) -> Void)? + + var body: some View { + VStack(spacing: 0) { + if tab.resultSets.count > 1 { + ResultTabBar( + resultSets: tab.resultSets, + activeResultSetId: activeResultSetBinding, + onClose: closeResultSet, + onPin: togglePin + ) + Divider() + } + + if let error = tab.activeResultSet?.errorMessage { + InlineErrorBanner( + message: error, + onDismiss: { tab.activeResultSet?.errorMessage = nil }, + onAIFix: nil + ) + Divider() + } + + resultContent + + // Note: MainStatusBarView integration happens in Phase 3 + } + } + + @ViewBuilder + private var resultContent: some View { + if let rs = tab.activeResultSet { + if !rs.resultColumns.isEmpty { + // Has data: DataGridView integration happens in Phase 3 + Text(String(format: String(localized: "DataGridView placeholder for: %@"), rs.label)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if rs.errorMessage == nil { + ResultSuccessView( + rowsAffected: rs.rowsAffected, + executionTime: rs.executionTime, + statusMessage: rs.statusMessage + ) + } + } else { + VStack(spacing: 8) { + Image(systemName: "text.cursor") + .font(.system(size: 32)) + .foregroundStyle(.secondary) + Text("Run a query to see results") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private var activeResultSetBinding: Binding { + Binding( + get: { tab.activeResultSetId }, + set: { newId in + if let coord = coordinator, + let tabIdx = coord.tabManager.selectedTabIndex { + coord.tabManager.tabs[tabIdx].activeResultSetId = newId + } + } + ) + } + + private func closeResultSet(_ id: UUID) { + guard let coord = coordinator, + let tabIdx = coord.tabManager.selectedTabIndex else { return } + coord.tabManager.tabs[tabIdx].resultSets.removeAll { $0.id == id } + if tab.activeResultSetId == id { + coord.tabManager.tabs[tabIdx].activeResultSetId = tab.resultSets.last?.id + } + } + + private func togglePin(_ id: UUID) { + guard let rs = tab.resultSets.first(where: { $0.id == id }) else { return } + rs.isPinned.toggle() + } +} + +#Preview("No results") { + let tab = QueryTab(title: "Query 1") + ResultsPanelView( + tab: tab, + connection: DatabaseConnection.preview + ) + .frame(width: 600, height: 400) +} From 28a75409fabd737e9190a098bc2c692461b03871 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: Mon, 30 Mar 2026 16:50:57 +0700 Subject: [PATCH 04/33] feat: collapsible results panel with auto-expand on query execution - Wrap resultsSection in conditional on isResultsCollapsed in queryTabContent - Smooth animation (200ms easeInOut) when toggling collapse - Auto-expand results when new query results arrive (single + multi-statement) - Table tabs and structure tabs unaffected --- TablePro/Views/Main/Child/MainEditorContentView.swift | 9 ++++++--- .../MainContentCoordinator+MultiStatement.swift | 4 ++++ .../Extensions/MainContentCoordinator+QueryHelpers.swift | 5 +++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 91c108b9..915da5c7 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -222,10 +222,13 @@ struct MainEditorContentView: View { } .frame(minHeight: 100, idealHeight: 200) - // Results (bottom) - resultsSection(tab: tab) - .frame(minHeight: 150) + // Results (bottom, collapsible) + if !tab.isResultsCollapsed { + resultsSection(tab: tab) + .frame(minHeight: 150) + } } + .animation(.easeInOut(duration: 0.2), value: tab.isResultsCollapsed) } private func updateHasQueryText() { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift index d8d64019..2085c165 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift @@ -132,6 +132,10 @@ extension MainContentCoordinator { updatedTab.isExecuting = false updatedTab.lastExecutedAt = Date() updatedTab.errorMessage = nil + // Auto-expand results pane when new results arrive + if updatedTab.isResultsCollapsed { + updatedTab.isResultsCollapsed = false + } tabManager.tabs[idx] = updatedTab if tabManager.selectedTabId == tabId { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift index c35dc61b..1c7f09ad 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift @@ -157,6 +157,11 @@ extension MainContentCoordinator { updatedTab.metadataVersion += 1 } + // Auto-expand results pane when new results arrive + if updatedTab.isResultsCollapsed { + updatedTab.isResultsCollapsed = false + } + tabManager.tabs[idx] = updatedTab // Cache column types for selective queries on subsequent page/filter/sort reloads. From e01a8fcea289d6ecb6e7fa4072969eb3b514de8b 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: Mon, 30 Mar 2026 16:52:40 +0700 Subject: [PATCH 05/33] feat: multi-statement execution produces ResultSet per statement - executeMultipleStatements builds a ResultSet for each statement with deep-copied rows, column types, execution time, and table name - applyPhase1Result creates a ResultSet sharing the same RowBuffer - Pinned results preserved on re-execution, unpinned replaced - Auto-expand collapsed results panel on new data --- ...ainContentCoordinator+MultiStatement.swift | 35 ++++++++++++++++++- .../MainContentCoordinator+QueryHelpers.swift | 23 +++++++++++- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift index 2085c165..38c176d4 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift @@ -41,6 +41,7 @@ extension MainContentCoordinator { var totalRowsAffected = 0 var executedCount = 0 var failedSQL: String? + var newResultSets: [ResultSet] = [] do { guard let driver = DatabaseManager.shared.driver(for: conn.id) else { @@ -70,6 +71,22 @@ extension MainContentCoordinator { lastSelectSQL = sql } + // Build a ResultSet for this statement + let stmtTableName = await MainActor.run { extractTableName(from: sql) } + let rs = ResultSet(label: stmtTableName ?? "Result \(stmtIndex + 1)") + // Deep copy to prevent C buffer retention issues + rs.rowBuffer = RowBuffer( + rows: result.rows.map { row in row.map { $0.map { String($0) } } }, + columns: result.columns.map { String($0) }, + columnTypes: result.columnTypes + ) + rs.executionTime = result.executionTime + rs.rowsAffected = result.rowsAffected + rs.statusMessage = result.statusMessage + rs.tableName = stmtTableName + rs.resultVersion = 1 + newResultSets.append(rs) + // Record with semicolon preserved for history/favorites let historySQL = sql.hasSuffix(";") ? sql : sql + ";" await MainActor.run { @@ -132,10 +149,15 @@ extension MainContentCoordinator { updatedTab.isExecuting = false updatedTab.lastExecutedAt = Date() updatedTab.errorMessage = nil - // Auto-expand results pane when new results arrive + + // Build ResultSet objects for each executed statement + let pinnedResults = updatedTab.resultSets.filter(\.isPinned) + updatedTab.resultSets = pinnedResults + newResultSets + updatedTab.activeResultSetId = newResultSets.last?.id if updatedTab.isResultsCollapsed { updatedTab.isResultsCollapsed = false } + tabManager.tabs[idx] = updatedTab if tabManager.selectedTabId == tabId { @@ -155,6 +177,11 @@ extension MainContentCoordinator { let contextMsg = "Statement \(failedStmtIndex)/\(totalCount) failed: " + error.localizedDescription + // Add an error ResultSet for the failed statement + let errorRS = ResultSet(label: "Error \(failedStmtIndex)") + errorRS.errorMessage = error.localizedDescription + newResultSets.append(errorRS) + await MainActor.run { currentQueryTask = nil toolbarState.setExecuting(false) @@ -164,6 +191,12 @@ extension MainContentCoordinator { errTab.errorMessage = contextMsg errTab.isExecuting = false errTab.executionTime = cumulativeTime + + // Attach accumulated ResultSets (successful + error) + let pinnedResults = errTab.resultSets.filter(\.isPinned) + errTab.resultSets = pinnedResults + newResultSets + errTab.activeResultSetId = newResultSets.last?.id + tabManager.tabs[idx] = errTab } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift index 1c7f09ad..490cab61 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift @@ -157,7 +157,28 @@ extension MainContentCoordinator { updatedTab.metadataVersion += 1 } - // Auto-expand results pane when new results arrive + // Create a ResultSet for this single-statement execution + let rs = ResultSet(label: tableName ?? "Result") + rs.rowBuffer = updatedTab.rowBuffer + rs.executionTime = updatedTab.executionTime + rs.rowsAffected = updatedTab.rowsAffected + rs.statusMessage = updatedTab.statusMessage + rs.tableName = updatedTab.tableName + rs.isEditable = updatedTab.isEditable + rs.resultVersion = updatedTab.resultVersion + rs.metadataVersion = updatedTab.metadataVersion + rs.columnTypes = updatedTab.columnTypes + rs.columnDefaults = updatedTab.columnDefaults + rs.columnForeignKeys = updatedTab.columnForeignKeys + rs.columnEnumValues = updatedTab.columnEnumValues + rs.columnNullable = updatedTab.columnNullable + + // Keep pinned results, replace unpinned + let pinned = updatedTab.resultSets.filter(\.isPinned) + updatedTab.resultSets = pinned + [rs] + updatedTab.activeResultSetId = rs.id + + // Auto-expand results panel when new data arrives if updatedTab.isResultsCollapsed { updatedTab.isResultsCollapsed = false } From 6b5ec45a2d03056de49edf121e55120f18aad675 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: Mon, 30 Mar 2026 16:55:39 +0700 Subject: [PATCH 06/33] fix: remove ResultTabBar preview with wrong init params --- TablePro/Views/Results/ResultTabBar.swift | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/TablePro/Views/Results/ResultTabBar.swift b/TablePro/Views/Results/ResultTabBar.swift index d806cfe9..b67c4208 100644 --- a/TablePro/Views/Results/ResultTabBar.swift +++ b/TablePro/Views/Results/ResultTabBar.swift @@ -66,18 +66,3 @@ struct ResultTabBar: View { } } -#Preview { - @Previewable @State var activeId: UUID? - let sets = [ - ResultSet(label: "Result 1", rowsAffected: 10), - ResultSet(label: "Result 2", rowsAffected: 5), - ResultSet(label: "Result 3", isPinned: true) - ] - ResultTabBar( - resultSets: sets, - activeResultSetId: $activeId, - onClose: { _ in }, - onPin: { _ in } - ) - .frame(width: 500) -} From 2138a1ecc024670e32a2f0cf28c69775e2621e15 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: Mon, 30 Mar 2026 17:07:30 +0700 Subject: [PATCH 07/33] feat: wire ResultTabBar + InlineErrorBanner + ResultSuccessView into resultsSection Replace old QuerySuccessView with ResultSuccessView, add result tab bar for multi-result display, add inline error banner. Uses real DataGridView (not placeholder). Structure and Explain paths unchanged. --- .../Main/Child/MainEditorContentView.swift | 89 +++++++++++++++---- 1 file changed, 70 insertions(+), 19 deletions(-) diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 915da5c7..31f5ab2f 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -299,30 +299,56 @@ struct MainEditorContentView: View { .frame(maxHeight: .infinity) } else if let explainText = tab.explainText { ExplainResultView(text: explainText, executionTime: tab.explainExecutionTime) - } else if tab.resultColumns.isEmpty && tab.errorMessage == nil - && tab.lastExecutedAt != nil && !tab.isExecuting - { - QuerySuccessView( - rowsAffected: tab.rowsAffected, - executionTime: tab.executionTime, - statusMessage: tab.statusMessage - ) } 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 } ) - .transition(.move(edge: .top).combined(with: .opacity)) Divider() } - dataGridView(tab: tab) + // 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 + { + ResultSuccessView( + rowsAffected: tab.rowsAffected, + executionTime: tab.executionTime, + statusMessage: tab.statusMessage + ) + } 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 + ) + .transition(.move(edge: .top).combined(with: .opacity)) + Divider() + } + + dataGridView(tab: tab) + } } statusBar(tab: tab) @@ -332,6 +358,31 @@ struct MainEditorContentView: View { .animation(.easeInOut(duration: 0.2), value: tab.errorMessage) } + private func resultTabBar(tab: QueryTab) -> some View { + ResultTabBar( + resultSets: tab.resultSets, + activeResultSetId: Binding( + get: { tab.activeResultSetId }, + set: { newId in + if let tabIdx = coordinator.tabManager.selectedTabIndex { + coordinator.tabManager.tabs[tabIdx].activeResultSetId = newId + } + } + ), + onClose: { id in + guard let tabIdx = coordinator.tabManager.selectedTabIndex else { return } + coordinator.tabManager.tabs[tabIdx].resultSets.removeAll { $0.id == id } + if tab.activeResultSetId == id { + coordinator.tabManager.tabs[tabIdx].activeResultSetId = + coordinator.tabManager.tabs[tabIdx].resultSets.last?.id + } + }, + onPin: { id in + tab.resultSets.first { $0.id == id }?.isPinned.toggle() + } + ) + } + @ViewBuilder private func dataGridView(tab: QueryTab) -> some View { DataGridView( From eb3bb0490da57017689b17ddd1befb1cd217ae94 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 30 Mar 2026 17:51:36 +0700 Subject: [PATCH 08/33] feat: add Cmd+Shift+W to close result tab, fix stale data on close, guard pinned tabs --- CHANGELOG.md | 1 + .../Models/UI/KeyboardShortcutModels.swift | 5 +- TablePro/Resources/Localizable.xcstrings | 172 +++++++++++++++++- TablePro/TableProApp.swift | 6 + .../Main/Child/MainEditorContentView.swift | 11 ++ .../Main/MainContentCommandActions.swift | 23 +++ TablePro/Views/Results/ResultsPanelView.swift | 14 +- docs/features/keyboard-shortcuts.mdx | 1 + 8 files changed, 229 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 672ea3a8..0cc20e8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Real-time SQL preview with syntax highlighting for CREATE TABLE DDL - Multi-database CREATE TABLE support: MySQL, PostgreSQL, SQLite, SQL Server, ClickHouse, DuckDB - Auto-fit column width: double-click column divider, right-click header → "Size to Fit" / "Size All Columns to Fit" +- Close Result Tab shortcut (`Cmd+Shift+W`) to close the active result tab when multiple are open ### Fixed diff --git a/TablePro/Models/UI/KeyboardShortcutModels.swift b/TablePro/Models/UI/KeyboardShortcutModels.swift index 2df686b0..5eb0cd2c 100644 --- a/TablePro/Models/UI/KeyboardShortcutModels.swift +++ b/TablePro/Models/UI/KeyboardShortcutModels.swift @@ -74,6 +74,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case toggleResults case previousResultTab case nextResultTab + case closeResultTab // Tabs case showPreviousTabBrackets @@ -98,7 +99,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { .duplicateRow, .truncateTable: return .edit case .toggleTableBrowser, .toggleInspector, .toggleFilters, .toggleHistory, - .toggleResults, .previousResultTab, .nextResultTab: + .toggleResults, .previousResultTab, .nextResultTab, .closeResultTab: return .view case .showPreviousTabBrackets, .showNextTabBrackets, .previousTabArrows, .nextTabArrows: @@ -144,6 +145,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case .toggleResults: return String(localized: "Toggle Results") case .previousResultTab: return String(localized: "Previous Result") case .nextResultTab: return String(localized: "Next Result") + case .closeResultTab: return String(localized: "Close Result Tab") case .showPreviousTabBrackets: return String(localized: "Show Previous Tab") case .showNextTabBrackets: return String(localized: "Show Next Tab") case .previousTabArrows: return String(localized: "Previous Tab (Alt)") @@ -450,6 +452,7 @@ struct KeyboardSettings: Codable, Equatable { .toggleResults: KeyCombo(key: "r", command: true, option: true), .previousResultTab: KeyCombo(key: "[", command: true), .nextResultTab: KeyCombo(key: "]", command: true), + .closeResultTab: KeyCombo(key: "w", command: true, shift: true), // Tabs .showPreviousTabBrackets: KeyCombo(key: "[", command: true, shift: true), diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index e164e87e..0454ccbb 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -251,6 +251,7 @@ } }, "(%lld active)" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -1278,6 +1279,9 @@ } } } + }, + "%lld row(s) affected" : { + }, "%lld row%@ affected" : { "localizations" : { @@ -2905,6 +2909,9 @@ } } } + }, + "Add at least one column with a name and type" : { + }, "Add Check Constraint" : { "localizations" : { @@ -2972,6 +2979,9 @@ } } } + }, + "Add columns to see the CREATE TABLE statement" : { + }, "Add Connection" : { "localizations" : { @@ -3016,8 +3026,12 @@ } } } + }, + "Add Filter" : { + }, "Add Filter (Cmd+Shift+F)" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -3038,6 +3052,9 @@ } } } + }, + "Add filter row" : { + }, "Add Folder..." : { "localizations" : { @@ -4050,8 +4067,12 @@ } } } + }, + "Apply" : { + }, "Apply All" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -4094,8 +4115,12 @@ } } } + }, + "Apply filters" : { + }, "Apply this filter" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -4118,6 +4143,7 @@ } }, "Apply This Filter" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -5638,6 +5664,9 @@ } } } + }, + "Charset:" : { + }, "Chat" : { "localizations" : { @@ -5978,6 +6007,7 @@ } }, "Clear search" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -6000,6 +6030,7 @@ } }, "Clear Search" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -6333,6 +6364,15 @@ } } } + }, + "Close Others" : { + + }, + "Close preview" : { + + }, + "Close Result Tab" : { + }, "Close Tab" : { "localizations" : { @@ -6423,6 +6463,9 @@ } } } + }, + "Collation:" : { + }, "Color" : { "localizations" : { @@ -6491,6 +6534,7 @@ } }, "Column" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -7660,6 +7704,9 @@ } } } + }, + "Copied to clipboard" : { + }, "Copied!" : { "localizations" : { @@ -8327,6 +8374,9 @@ } } } + }, + "Create New Table..." : { + }, "Create New Tag" : { "localizations" : { @@ -8393,6 +8443,12 @@ } } } + }, + "Create Table" : { + + }, + "Create Table Failed" : { + }, "Created" : { "localizations" : { @@ -9337,6 +9393,9 @@ } } } + }, + "DataGridView placeholder for: %@" : { + }, "Date format:" : { "localizations" : { @@ -10119,6 +10178,9 @@ } } } + }, + "Delete Selected" : { + }, "Delete SSH Profile?" : { "localizations" : { @@ -10791,6 +10853,7 @@ } }, "Duplicate filter" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -11548,6 +11611,9 @@ } } } + }, + "Engine:" : { + }, "Enter a name for this filter preset" : { "localizations" : { @@ -11614,6 +11680,9 @@ } } } + }, + "Enter table name" : { + }, "Enter the passphrase to decrypt and import connections." : { "localizations" : { @@ -13835,8 +13904,12 @@ } } } + }, + "Filter column" : { + }, "Filter column: %@" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -13901,8 +13974,12 @@ } } } + }, + "Filter operator" : { + }, "Filter operator: %@" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -13923,8 +14000,12 @@ } } } + }, + "Filter options" : { + }, "Filter presets" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -13947,6 +14028,7 @@ } }, "Filter settings" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -13969,6 +14051,7 @@ } }, "Filter Settings" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -13989,6 +14072,12 @@ } } } + }, + "Filter Settings..." : { + + }, + "Filter value" : { + }, "Filter with column" : { "localizations" : { @@ -14077,6 +14166,9 @@ } } } + }, + "Fix with AI" : { + }, "Focus Border" : { "localizations" : { @@ -14189,6 +14281,9 @@ } } } + }, + "Foreign Keys" : { + }, "Forever" : { "localizations" : { @@ -14897,6 +14992,7 @@ } }, "History Limit:" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -15903,7 +15999,6 @@ } }, "Indexes" : { - "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -19552,6 +19647,9 @@ } } } + }, + "Next Result" : { + }, "Next Tab" : { "localizations" : { @@ -21710,6 +21808,12 @@ } } } + }, + "Open File" : { + + }, + "Open File..." : { + }, "Open MQL Editor" : { "extractionState" : "stale", @@ -22543,6 +22647,9 @@ } } } + }, + "Pin Result" : { + }, "Pink" : { "localizations" : { @@ -23482,6 +23589,9 @@ } } } + }, + "Preview Query" : { + }, "Preview Schema Changes" : { "localizations" : { @@ -23571,6 +23681,9 @@ } } } + }, + "Previous Result" : { + }, "Previous Tab" : { "localizations" : { @@ -24221,6 +24334,7 @@ } }, "Query History:" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -24287,6 +24401,7 @@ } }, "Quick search across all columns..." : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -25072,6 +25187,9 @@ } } } + }, + "Remove all filters and reload" : { + }, "Remove filter" : { "localizations" : { @@ -25116,6 +25234,9 @@ } } } + }, + "Remove filter row" : { + }, "Remove Folder" : { "localizations" : { @@ -25981,6 +26102,9 @@ } } } + }, + "Run a query to see results" : { + }, "Run in New Tab" : { "localizations" : { @@ -26182,6 +26306,7 @@ } }, "Save and load filter presets" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -26224,6 +26349,9 @@ } } } + }, + "Save As" : { + }, "Save as Favorite" : { "localizations" : { @@ -26313,6 +26441,9 @@ } } } + }, + "Save As..." : { + }, "Save Changes" : { "localizations" : { @@ -26473,6 +26604,9 @@ } } } + }, + "Save SQL file" : { + }, "Save Table Template" : { "extractionState" : "stale", @@ -26895,6 +27029,9 @@ } } } + }, + "Second filter value" : { + }, "Second value is required for BETWEEN" : { "localizations" : { @@ -27213,8 +27350,12 @@ } } } + }, + "Select filter column" : { + }, "Select filter for %@" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -27235,6 +27376,9 @@ } } } + }, + "Select filter operator" : { + }, "Select Plugin" : { "localizations" : { @@ -27280,6 +27424,9 @@ } } } + }, + "Select SQL files to open" : { + }, "Select Tab %lld" : { "localizations" : { @@ -28297,6 +28444,12 @@ } } } + }, + "Size All Columns to Fit" : { + + }, + "Size to Fit" : { + }, "Size:" : { "extractionState" : "stale", @@ -28699,7 +28852,6 @@ } }, "SQL Preview" : { - "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -30058,6 +30210,7 @@ } }, "Syncs connections, settings, and history across your Macs via iCloud." : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -30078,6 +30231,9 @@ } } } + }, + "Syncs connections, settings, and SSH profiles across your Macs via iCloud." : { + }, "Syncs passwords via iCloud Keychain (end-to-end encrypted)." : { "localizations" : { @@ -30353,6 +30509,9 @@ } } } + }, + "Table Name:" : { + }, "Table: %@" : { "localizations" : { @@ -32140,6 +32299,9 @@ } } } + }, + "Toggle Results" : { + }, "Toggle Table Browser" : { "localizations" : { @@ -32934,6 +33096,9 @@ } } } + }, + "Unpin" : { + }, "Unset" : { "localizations" : { @@ -34125,6 +34290,9 @@ } } } + }, + "WHERE clause" : { + }, "WHERE clause..." : { "localizations" : { diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index cd0f9886..72fa4a77 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -387,6 +387,12 @@ struct AppMenuCommands: Commands { } .optionalKeyboardShortcut(shortcut(for: .nextResultTab)) .disabled(!appState.isConnected) + + Button("Close Result Tab") { + actions?.closeResultTab() + } + .optionalKeyboardShortcut(shortcut(for: .closeResultTab)) + .disabled(!appState.isConnected) } // Tab navigation shortcuts — native macOS window tabs diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 31f5ab2f..f2d7aa7a 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -371,11 +371,22 @@ struct MainEditorContentView: View { ), onClose: { id in guard let tabIdx = coordinator.tabManager.selectedTabIndex else { return } + let rs = coordinator.tabManager.tabs[tabIdx].resultSets.first { $0.id == id } + guard rs?.isPinned != true else { return } coordinator.tabManager.tabs[tabIdx].resultSets.removeAll { $0.id == id } if tab.activeResultSetId == id { coordinator.tabManager.tabs[tabIdx].activeResultSetId = coordinator.tabManager.tabs[tabIdx].resultSets.last?.id } + if coordinator.tabManager.tabs[tabIdx].resultSets.isEmpty { + coordinator.tabManager.tabs[tabIdx].resultColumns = [] + coordinator.tabManager.tabs[tabIdx].columnTypes = [] + coordinator.tabManager.tabs[tabIdx].resultRows = [] + coordinator.tabManager.tabs[tabIdx].errorMessage = nil + coordinator.tabManager.tabs[tabIdx].rowsAffected = 0 + coordinator.tabManager.tabs[tabIdx].executionTime = nil + coordinator.tabManager.tabs[tabIdx].statusMessage = nil + } }, onPin: { id in tab.resultSets.first { $0.id == id }?.isPinned.toggle() diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index f44e1ab9..23b6a318 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -563,6 +563,29 @@ final class MainContentCommandActions { coordinator.tabManager.tabs[tabIndex].activeResultSetId = tab.resultSets[currentIndex + 1].id } + func closeResultTab() { + guard let tabIdx = coordinator?.tabManager.selectedTabIndex else { return } + guard let tabs = coordinator?.tabManager.tabs, !tabs[tabIdx].resultSets.isEmpty else { return } + guard let activeId = tabs[tabIdx].activeResultSetId ?? tabs[tabIdx].resultSets.last?.id else { return } + let rs = tabs[tabIdx].resultSets.first { $0.id == activeId } + guard rs?.isPinned != true else { return } + coordinator?.tabManager.tabs[tabIdx].resultSets.removeAll { $0.id == activeId } + if tabs[tabIdx].activeResultSetId == activeId { + coordinator?.tabManager.tabs[tabIdx].activeResultSetId = + coordinator?.tabManager.tabs[tabIdx].resultSets.last?.id + } + // Clear legacy properties when no result sets remain + if coordinator?.tabManager.tabs[tabIdx].resultSets.isEmpty == true { + coordinator?.tabManager.tabs[tabIdx].resultColumns = [] + coordinator?.tabManager.tabs[tabIdx].columnTypes = [] + coordinator?.tabManager.tabs[tabIdx].resultRows = [] + coordinator?.tabManager.tabs[tabIdx].errorMessage = nil + coordinator?.tabManager.tabs[tabIdx].rowsAffected = 0 + coordinator?.tabManager.tabs[tabIdx].executionTime = nil + coordinator?.tabManager.tabs[tabIdx].statusMessage = nil + } + } + // MARK: - Database Operations (Group A — Called Directly) func openDatabaseSwitcher() { diff --git a/TablePro/Views/Results/ResultsPanelView.swift b/TablePro/Views/Results/ResultsPanelView.swift index d796be47..808f5f9b 100644 --- a/TablePro/Views/Results/ResultsPanelView.swift +++ b/TablePro/Views/Results/ResultsPanelView.swift @@ -85,9 +85,21 @@ struct ResultsPanelView: View { private func closeResultSet(_ id: UUID) { guard let coord = coordinator, let tabIdx = coord.tabManager.selectedTabIndex else { return } + let rs = coord.tabManager.tabs[tabIdx].resultSets.first { $0.id == id } + guard rs?.isPinned != true else { return } coord.tabManager.tabs[tabIdx].resultSets.removeAll { $0.id == id } if tab.activeResultSetId == id { - coord.tabManager.tabs[tabIdx].activeResultSetId = tab.resultSets.last?.id + coord.tabManager.tabs[tabIdx].activeResultSetId = + coord.tabManager.tabs[tabIdx].resultSets.last?.id + } + if coord.tabManager.tabs[tabIdx].resultSets.isEmpty { + coord.tabManager.tabs[tabIdx].resultColumns = [] + coord.tabManager.tabs[tabIdx].columnTypes = [] + coord.tabManager.tabs[tabIdx].resultRows = [] + coord.tabManager.tabs[tabIdx].errorMessage = nil + coord.tabManager.tabs[tabIdx].rowsAffected = 0 + coord.tabManager.tabs[tabIdx].executionTime = nil + coord.tabManager.tabs[tabIdx].statusMessage = nil } } diff --git a/docs/features/keyboard-shortcuts.mdx b/docs/features/keyboard-shortcuts.mdx index 3ae641e1..ad1366bc 100644 --- a/docs/features/keyboard-shortcuts.mdx +++ b/docs/features/keyboard-shortcuts.mdx @@ -188,6 +188,7 @@ TablePro is keyboard-driven. Most actions have shortcuts, and most menu shortcut |--------|----------| | Previous Result | `Cmd+[` | | Next Result | `Cmd+]` | +| Close Result Tab | `Cmd+Shift+W` | ### AI From cf882b798f8face3459d2491c19e98dda073e550 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 30 Mar 2026 18:06:30 +0700 Subject: [PATCH 09/33] feat: add toolbar button for toggling results panel --- TablePro/Models/Connection/ConnectionToolbarState.swift | 3 +++ .../MainContentCoordinator+MultiStatement.swift | 1 + .../Extensions/MainContentCoordinator+QueryHelpers.swift | 1 + .../Extensions/MainContentCoordinator+TabSwitch.swift | 2 ++ TablePro/Views/Main/MainContentCommandActions.swift | 1 + TablePro/Views/Toolbar/TableProToolbarView.swift | 8 ++++++++ 6 files changed, 16 insertions(+) diff --git a/TablePro/Models/Connection/ConnectionToolbarState.swift b/TablePro/Models/Connection/ConnectionToolbarState.swift index 17964dbc..667213d5 100644 --- a/TablePro/Models/Connection/ConnectionToolbarState.swift +++ b/TablePro/Models/Connection/ConnectionToolbarState.swift @@ -174,6 +174,9 @@ final class ConnectionToolbarState { /// Whether the current tab is a table tab (enables filter/sort actions) var isTableTab: Bool = false + /// Whether the results panel is collapsed + var isResultsCollapsed: Bool = false + /// Whether there are pending changes (data grid or file) var hasPendingChanges: Bool = false diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift index 38c176d4..c9664a56 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift @@ -157,6 +157,7 @@ extension MainContentCoordinator { if updatedTab.isResultsCollapsed { updatedTab.isResultsCollapsed = false } + toolbarState.isResultsCollapsed = false tabManager.tabs[idx] = updatedTab diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift index 490cab61..b8466371 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift @@ -182,6 +182,7 @@ extension MainContentCoordinator { if updatedTab.isResultsCollapsed { updatedTab.isResultsCollapsed = false } + toolbarState.isResultsCollapsed = false tabManager.tabs[idx] = updatedTab diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift index 6ce8e252..1ef59856 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift @@ -51,6 +51,7 @@ extension MainContentCoordinator { selectedRowIndices = newTab.selectedRowIndices AppState.shared.isCurrentTabEditable = newTab.isEditable && !newTab.isView && newTab.tableName != nil toolbarState.isTableTab = newTab.tabType == .table + toolbarState.isResultsCollapsed = newTab.isResultsCollapsed AppState.shared.isTableTab = newTab.tabType == .table // Configure change manager without triggering reload yet — we'll fire a single @@ -122,6 +123,7 @@ extension MainContentCoordinator { } else { AppState.shared.isCurrentTabEditable = false toolbarState.isTableTab = false + toolbarState.isResultsCollapsed = false AppState.shared.isTableTab = false } } diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 23b6a318..6ba0be5f 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -541,6 +541,7 @@ final class MainContentCommandActions { func toggleResults() { guard let coordinator, let tabIndex = coordinator.tabManager.selectedTabIndex else { return } coordinator.tabManager.tabs[tabIndex].isResultsCollapsed.toggle() + coordinator.toolbarState.isResultsCollapsed = coordinator.tabManager.tabs[tabIndex].isResultsCollapsed } func previousResultTab() { diff --git a/TablePro/Views/Toolbar/TableProToolbarView.swift b/TablePro/Views/Toolbar/TableProToolbarView.swift index 8f9b90ba..4a31d342 100644 --- a/TablePro/Views/Toolbar/TableProToolbarView.swift +++ b/TablePro/Views/Toolbar/TableProToolbarView.swift @@ -160,6 +160,14 @@ struct TableProToolbar: ViewModifier { } } + ToolbarItem(placement: .primaryAction) { + Button { actions?.toggleResults() } label: { + Label("Results", systemImage: "rectangle.bottomhalf.inset.filled") + } + .help("Toggle Results (⌘⌥R)") + .disabled(state.connectionState != .connected || state.isTableTab) + } + ToolbarItem(placement: .primaryAction) { Button { actions?.toggleRightSidebar() From fe707a1a63869b96a0c7ddbacd232fdae8c33c80 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 30 Mar 2026 18:07:18 +0700 Subject: [PATCH 10/33] fix: group Results + Inspector into ToolbarItemGroup to fix ViewBuilder 10-item limit --- TablePro/Views/Toolbar/TableProToolbarView.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/TablePro/Views/Toolbar/TableProToolbarView.swift b/TablePro/Views/Toolbar/TableProToolbarView.swift index 4a31d342..f88b91c5 100644 --- a/TablePro/Views/Toolbar/TableProToolbarView.swift +++ b/TablePro/Views/Toolbar/TableProToolbarView.swift @@ -160,15 +160,13 @@ struct TableProToolbar: ViewModifier { } } - ToolbarItem(placement: .primaryAction) { + ToolbarItemGroup(placement: .primaryAction) { Button { actions?.toggleResults() } label: { Label("Results", systemImage: "rectangle.bottomhalf.inset.filled") } .help("Toggle Results (⌘⌥R)") .disabled(state.connectionState != .connected || state.isTableTab) - } - ToolbarItem(placement: .primaryAction) { Button { actions?.toggleRightSidebar() } label: { From f816cb441d8b0dc5e217ea46313382988afd66bd Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 30 Mar 2026 18:09:51 +0700 Subject: [PATCH 11/33] fix: remove VSplitView animation on results collapse to prevent DataGridView layout gap --- TablePro/Views/Main/Child/MainEditorContentView.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index f2d7aa7a..b37e06e6 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -228,7 +228,8 @@ struct MainEditorContentView: View { .frame(minHeight: 150) } } - .animation(.easeInOut(duration: 0.2), value: tab.isResultsCollapsed) + // No animation on collapse/expand — VSplitView + NSTableView layout + // conflicts cause header/border gaps during animated child insertion. } private func updateHasQueryText() { From 87d811c3f17693d9bb73319e3c05868f004bc3e1 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 30 Mar 2026 18:11:18 +0700 Subject: [PATCH 12/33] feat: hide Results toolbar button on table tabs instead of disabling --- .../Views/Toolbar/TableProToolbarView.swift | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/TablePro/Views/Toolbar/TableProToolbarView.swift b/TablePro/Views/Toolbar/TableProToolbarView.swift index f88b91c5..e52aec77 100644 --- a/TablePro/Views/Toolbar/TableProToolbarView.swift +++ b/TablePro/Views/Toolbar/TableProToolbarView.swift @@ -117,7 +117,7 @@ struct TableProToolbar: ViewModifier { // MARK: - Primary Action (Right) - ToolbarItem(placement: .primaryAction) { + ToolbarItemGroup(placement: .primaryAction) { Button { actions?.openQuickSwitcher() } label: { @@ -125,9 +125,7 @@ struct TableProToolbar: ViewModifier { } .help("Quick Switcher (⌘P)") .disabled(state.connectionState != .connected) - } - ToolbarItem(placement: .primaryAction) { Button { actions?.newTab() } label: { @@ -160,13 +158,17 @@ struct TableProToolbar: ViewModifier { } } - ToolbarItemGroup(placement: .primaryAction) { - Button { actions?.toggleResults() } label: { - Label("Results", systemImage: "rectangle.bottomhalf.inset.filled") + if !state.isTableTab { + ToolbarItem(placement: .primaryAction) { + Button { actions?.toggleResults() } label: { + Label("Results", systemImage: "rectangle.bottomhalf.inset.filled") + } + .help("Toggle Results (⌘⌥R)") + .disabled(state.connectionState != .connected) } - .help("Toggle Results (⌘⌥R)") - .disabled(state.connectionState != .connected || state.isTableTab) + } + ToolbarItem(placement: .primaryAction) { Button { actions?.toggleRightSidebar() } label: { From a6356f19269fef16d00905d77c0e333d9fe3b793 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 30 Mar 2026 18:12:37 +0700 Subject: [PATCH 13/33] fix: add idealHeight to results section so VSplitView restores proper height after collapse --- TablePro/Views/Main/Child/MainEditorContentView.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index b37e06e6..5a44e491 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -223,9 +223,11 @@ struct MainEditorContentView: View { .frame(minHeight: 100, idealHeight: 200) // Results (bottom, collapsible) + // idealHeight ensures VSplitView allocates ~50% to results + // when re-inserting after collapse (otherwise it defaults to minHeight). if !tab.isResultsCollapsed { resultsSection(tab: tab) - .frame(minHeight: 150) + .frame(minHeight: 150, idealHeight: 350) } } // No animation on collapse/expand — VSplitView + NSTableView layout From 69e26048500e51346e0d564ab6b9b372ac8e576f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 30 Mar 2026 18:20:44 +0700 Subject: [PATCH 14/33] refactor: replace VSplitView with NSSplitViewController for query tab split --- TablePro/Views/Editor/QuerySplitView.swift | 70 +++++++ .../Main/Child/MainEditorContentView.swift | 183 ++++++------------ 2 files changed, 128 insertions(+), 125 deletions(-) create mode 100644 TablePro/Views/Editor/QuerySplitView.swift diff --git a/TablePro/Views/Editor/QuerySplitView.swift b/TablePro/Views/Editor/QuerySplitView.swift new file mode 100644 index 00000000..37210515 --- /dev/null +++ b/TablePro/Views/Editor/QuerySplitView.swift @@ -0,0 +1,70 @@ +// +// QuerySplitView.swift +// TablePro +// +// NSSplitViewController wrapper for the query editor / results split. +// Uses NSSplitViewItem.isCollapsed for proper collapse/expand with divider +// position preservation, and autosaveName for cross-session persistence. +// + +import AppKit +import SwiftUI + +struct QuerySplitView: NSViewControllerRepresentable { + var isBottomCollapsed: Bool + var autosaveName: String + @ViewBuilder var topContent: TopContent + @ViewBuilder var bottomContent: BottomContent + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeNSViewController(context: Context) -> NSSplitViewController { + let splitVC = NSSplitViewController() + splitVC.splitView.isVertical = false + splitVC.splitView.dividerStyle = .thin + splitVC.splitView.autosaveName = autosaveName + + let topHosting = NSHostingController(rootView: topContent) + let bottomHosting = NSHostingController(rootView: bottomContent) + + let topItem = NSSplitViewItem(viewController: topHosting) + topItem.minimumThickness = 100 + topItem.holdingPriority = .init(240) + + let bottomItem = NSSplitViewItem(viewController: bottomHosting) + bottomItem.minimumThickness = 150 + bottomItem.holdingPriority = .init(260) + bottomItem.canCollapse = true + bottomItem.isCollapsed = isBottomCollapsed + + splitVC.addSplitViewItem(topItem) + splitVC.addSplitViewItem(bottomItem) + + context.coordinator.topHostingController = topHosting + context.coordinator.bottomHostingController = bottomHosting + + return splitVC + } + + func updateNSViewController(_ splitVC: NSSplitViewController, context: Context) { + guard splitVC.splitViewItems.count == 2 else { return } + let bottomItem = splitVC.splitViewItems[1] + if bottomItem.isCollapsed != isBottomCollapsed { + bottomItem.animator().isCollapsed = isBottomCollapsed + } + + context.coordinator.topHostingController?.rootView = topContent + context.coordinator.bottomHostingController?.rootView = bottomContent + } + + final class Coordinator: NSObject, NSSplitViewDelegate { + var topHostingController: NSHostingController? + var bottomHostingController: NSHostingController? + + func splitView(_ splitView: NSSplitView, canCollapseSubview subview: NSView) -> Bool { + subview == splitView.subviews.last + } + } +} diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 5a44e491..6f942713 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -185,53 +185,48 @@ struct MainEditorContentView: View { @ViewBuilder private func queryTabContent(tab: QueryTab) -> some View { @Bindable var bindableCoordinator = coordinator - VSplitView { - // Query Editor (top) - VStack(spacing: 0) { - QueryEditorView( - queryText: queryTextBinding(for: tab), - cursorPositions: $bindableCoordinator.cursorPositions, - onExecute: { coordinator.runQuery() }, - schemaProvider: coordinator.schemaProvider, - databaseType: coordinator.connection.type, - connectionId: coordinator.connection.id, - onCloseTab: { - NSApp.keyWindow?.close() - }, - onExecuteQuery: { coordinator.runQuery() }, - onExplain: { variant in - if let variant { - coordinator.runClickHouseExplain(variant: variant) - } else { - coordinator.runExplainQuery() + QuerySplitView( + isBottomCollapsed: tab.isResultsCollapsed, + autosaveName: "QuerySplit-\(connectionId)-\(tab.id)", + topContent: { + VStack(spacing: 0) { + QueryEditorView( + queryText: queryTextBinding(for: tab), + cursorPositions: $bindableCoordinator.cursorPositions, + onExecute: { coordinator.runQuery() }, + schemaProvider: coordinator.schemaProvider, + databaseType: coordinator.connection.type, + connectionId: coordinator.connection.id, + onCloseTab: { + NSApp.keyWindow?.close() + }, + onExecuteQuery: { coordinator.runQuery() }, + onExplain: { variant in + if let variant { + coordinator.runClickHouseExplain(variant: variant) + } else { + coordinator.runExplainQuery() + } + }, + onAIExplain: { text in + coordinator.showAIChatPanel() + coordinator.aiViewModel?.handleExplainSelection(text) + }, + onAIOptimize: { text in + coordinator.showAIChatPanel() + coordinator.aiViewModel?.handleOptimizeSelection(text) + }, + onSaveAsFavorite: { text in + guard !text.isEmpty else { return } + favoriteDialogQuery = FavoriteDialogQuery(query: text) } - }, - onAIExplain: { text in - coordinator.showAIChatPanel() - coordinator.aiViewModel?.handleExplainSelection(text) - }, - onAIOptimize: { text in - coordinator.showAIChatPanel() - coordinator.aiViewModel?.handleOptimizeSelection(text) - }, - onSaveAsFavorite: { text in - guard !text.isEmpty else { return } - favoriteDialogQuery = FavoriteDialogQuery(query: text) - } - ) - } - .frame(minHeight: 100, idealHeight: 200) - - // Results (bottom, collapsible) - // idealHeight ensures VSplitView allocates ~50% to results - // when re-inserting after collapse (otherwise it defaults to minHeight). - if !tab.isResultsCollapsed { + ) + } + }, + bottomContent: { resultsSection(tab: tab) - .frame(minHeight: 150, idealHeight: 350) } - } - // No animation on collapse/expand — VSplitView + NSTableView layout - // conflicts cause header/border gaps during animated child insertion. + ) } private func updateHasQueryText() { @@ -302,56 +297,30 @@ struct MainEditorContentView: View { .frame(maxHeight: .infinity) } else if let explainText = tab.explainText { ExplainResultView(text: explainText, executionTime: tab.explainExecutionTime) + } else if tab.resultColumns.isEmpty && tab.errorMessage == nil + && tab.lastExecutedAt != nil && !tab.isExecuting + { + QuerySuccessView( + rowsAffected: tab.rowsAffected, + executionTime: tab.executionTime, + statusMessage: tab.statusMessage + ) } 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 } + // 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 ) + .transition(.move(edge: .top).combined(with: .opacity)) Divider() } - // 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 - { - ResultSuccessView( - rowsAffected: tab.rowsAffected, - executionTime: tab.executionTime, - statusMessage: tab.statusMessage - ) - } 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 - ) - .transition(.move(edge: .top).combined(with: .opacity)) - Divider() - } - - dataGridView(tab: tab) - } + dataGridView(tab: tab) } statusBar(tab: tab) @@ -361,42 +330,6 @@ struct MainEditorContentView: View { .animation(.easeInOut(duration: 0.2), value: tab.errorMessage) } - private func resultTabBar(tab: QueryTab) -> some View { - ResultTabBar( - resultSets: tab.resultSets, - activeResultSetId: Binding( - get: { tab.activeResultSetId }, - set: { newId in - if let tabIdx = coordinator.tabManager.selectedTabIndex { - coordinator.tabManager.tabs[tabIdx].activeResultSetId = newId - } - } - ), - onClose: { id in - guard let tabIdx = coordinator.tabManager.selectedTabIndex else { return } - let rs = coordinator.tabManager.tabs[tabIdx].resultSets.first { $0.id == id } - guard rs?.isPinned != true else { return } - coordinator.tabManager.tabs[tabIdx].resultSets.removeAll { $0.id == id } - if tab.activeResultSetId == id { - coordinator.tabManager.tabs[tabIdx].activeResultSetId = - coordinator.tabManager.tabs[tabIdx].resultSets.last?.id - } - if coordinator.tabManager.tabs[tabIdx].resultSets.isEmpty { - coordinator.tabManager.tabs[tabIdx].resultColumns = [] - coordinator.tabManager.tabs[tabIdx].columnTypes = [] - coordinator.tabManager.tabs[tabIdx].resultRows = [] - coordinator.tabManager.tabs[tabIdx].errorMessage = nil - coordinator.tabManager.tabs[tabIdx].rowsAffected = 0 - coordinator.tabManager.tabs[tabIdx].executionTime = nil - coordinator.tabManager.tabs[tabIdx].statusMessage = nil - } - }, - onPin: { id in - tab.resultSets.first { $0.id == id }?.isPinned.toggle() - } - ) - } - @ViewBuilder private func dataGridView(tab: QueryTab) -> some View { DataGridView( From c05461677e6c64f37abfbf7a50fc86d1d67f1f63 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 30 Mar 2026 18:23:42 +0700 Subject: [PATCH 15/33] fix: set sizingOptions = [] on NSHostingControllers so panes fill split view --- TablePro/Views/Editor/QuerySplitView.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/TablePro/Views/Editor/QuerySplitView.swift b/TablePro/Views/Editor/QuerySplitView.swift index 37210515..9be1b42c 100644 --- a/TablePro/Views/Editor/QuerySplitView.swift +++ b/TablePro/Views/Editor/QuerySplitView.swift @@ -27,7 +27,9 @@ struct QuerySplitView: NSViewControllerRe splitVC.splitView.autosaveName = autosaveName let topHosting = NSHostingController(rootView: topContent) + topHosting.sizingOptions = [] let bottomHosting = NSHostingController(rootView: bottomContent) + bottomHosting.sizingOptions = [] let topItem = NSSplitViewItem(viewController: topHosting) topItem.minimumThickness = 100 From 7ca56c64700c36cae27eb85b6cc0314da8a40407 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 30 Mar 2026 18:24:43 +0700 Subject: [PATCH 16/33] fix: add frame fill modifiers so SwiftUI content expands in NSSplitView panes --- TablePro/Views/Main/Child/MainEditorContentView.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 6f942713..6c160575 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -222,9 +222,11 @@ struct MainEditorContentView: View { } ) } + .frame(maxWidth: .infinity, maxHeight: .infinity) }, bottomContent: { resultsSection(tab: tab) + .frame(maxWidth: .infinity, maxHeight: .infinity) } ) } From 6f5ec4acc2dcbcc443a16f8d4625b05596597820 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 30 Mar 2026 18:26:54 +0700 Subject: [PATCH 17/33] fix: enable translatesAutoresizingMaskIntoConstraints for NSSplitView frame-based layout --- TablePro/Views/Editor/QuerySplitView.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/TablePro/Views/Editor/QuerySplitView.swift b/TablePro/Views/Editor/QuerySplitView.swift index 9be1b42c..b457c27f 100644 --- a/TablePro/Views/Editor/QuerySplitView.swift +++ b/TablePro/Views/Editor/QuerySplitView.swift @@ -28,8 +28,10 @@ struct QuerySplitView: NSViewControllerRe let topHosting = NSHostingController(rootView: topContent) topHosting.sizingOptions = [] + topHosting.view.translatesAutoresizingMaskIntoConstraints = true let bottomHosting = NSHostingController(rootView: bottomContent) bottomHosting.sizingOptions = [] + bottomHosting.view.translatesAutoresizingMaskIntoConstraints = true let topItem = NSSplitViewItem(viewController: topHosting) topItem.minimumThickness = 100 From 8afee826a4e2dbad7235d548cc36d21bc3f4f54d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 30 Mar 2026 18:28:35 +0700 Subject: [PATCH 18/33] fix: wrap NSHostingController in container VC with Auto Layout edge constraints --- TablePro/Views/Editor/QuerySplitView.swift | 61 ++++++++++++++++------ 1 file changed, 46 insertions(+), 15 deletions(-) diff --git a/TablePro/Views/Editor/QuerySplitView.swift b/TablePro/Views/Editor/QuerySplitView.swift index b457c27f..0f6a7e02 100644 --- a/TablePro/Views/Editor/QuerySplitView.swift +++ b/TablePro/Views/Editor/QuerySplitView.swift @@ -26,18 +26,14 @@ struct QuerySplitView: NSViewControllerRe splitVC.splitView.dividerStyle = .thin splitVC.splitView.autosaveName = autosaveName - let topHosting = NSHostingController(rootView: topContent) - topHosting.sizingOptions = [] - topHosting.view.translatesAutoresizingMaskIntoConstraints = true - let bottomHosting = NSHostingController(rootView: bottomContent) - bottomHosting.sizingOptions = [] - bottomHosting.view.translatesAutoresizingMaskIntoConstraints = true - - let topItem = NSSplitViewItem(viewController: topHosting) + let topContainer = HostingContainerController(rootView: topContent) + let bottomContainer = HostingContainerController(rootView: bottomContent) + + let topItem = NSSplitViewItem(viewController: topContainer) topItem.minimumThickness = 100 topItem.holdingPriority = .init(240) - let bottomItem = NSSplitViewItem(viewController: bottomHosting) + let bottomItem = NSSplitViewItem(viewController: bottomContainer) bottomItem.minimumThickness = 150 bottomItem.holdingPriority = .init(260) bottomItem.canCollapse = true @@ -46,8 +42,8 @@ struct QuerySplitView: NSViewControllerRe splitVC.addSplitViewItem(topItem) splitVC.addSplitViewItem(bottomItem) - context.coordinator.topHostingController = topHosting - context.coordinator.bottomHostingController = bottomHosting + context.coordinator.topContainer = topContainer + context.coordinator.bottomContainer = bottomContainer return splitVC } @@ -59,16 +55,51 @@ struct QuerySplitView: NSViewControllerRe bottomItem.animator().isCollapsed = isBottomCollapsed } - context.coordinator.topHostingController?.rootView = topContent - context.coordinator.bottomHostingController?.rootView = bottomContent + context.coordinator.topContainer?.hostingController.rootView = topContent + context.coordinator.bottomContainer?.hostingController.rootView = bottomContent } final class Coordinator: NSObject, NSSplitViewDelegate { - var topHostingController: NSHostingController? - var bottomHostingController: NSHostingController? + var topContainer: HostingContainerController? + var bottomContainer: HostingContainerController? func splitView(_ splitView: NSSplitView, canCollapseSubview subview: NSView) -> Bool { subview == splitView.subviews.last } } } + +// MARK: - Hosting Container + +/// Wraps NSHostingController in a plain NSViewController with Auto Layout +/// constraints pinning the hosted SwiftUI view to all edges. This ensures +/// the SwiftUI content fills the NSSplitView pane instead of rendering +/// at its intrinsic size. +final class HostingContainerController: NSViewController { + let hostingController: NSHostingController + + init(rootView: Content) { + self.hostingController = NSHostingController(rootView: rootView) + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + override func loadView() { + view = NSView() + } + + override func viewDidLoad() { + super.viewDidLoad() + addChild(hostingController) + hostingController.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(hostingController.view) + NSLayoutConstraint.activate([ + hostingController.view.topAnchor.constraint(equalTo: view.topAnchor), + hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + } +} From 8edb0f118160331d9e6a9724ee36224d466415fa Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 30 Mar 2026 18:30:34 +0700 Subject: [PATCH 19/33] fix: use NSHostingView as pane view so NSSplitView frame drives SwiftUI layout --- TablePro/Views/Editor/QuerySplitView.swift | 49 +++++++++------------- 1 file changed, 19 insertions(+), 30 deletions(-) diff --git a/TablePro/Views/Editor/QuerySplitView.swift b/TablePro/Views/Editor/QuerySplitView.swift index 0f6a7e02..34d5ee1a 100644 --- a/TablePro/Views/Editor/QuerySplitView.swift +++ b/TablePro/Views/Editor/QuerySplitView.swift @@ -26,14 +26,14 @@ struct QuerySplitView: NSViewControllerRe splitVC.splitView.dividerStyle = .thin splitVC.splitView.autosaveName = autosaveName - let topContainer = HostingContainerController(rootView: topContent) - let bottomContainer = HostingContainerController(rootView: bottomContent) + let topVC = HostingPaneController(rootView: topContent) + let bottomVC = HostingPaneController(rootView: bottomContent) - let topItem = NSSplitViewItem(viewController: topContainer) + let topItem = NSSplitViewItem(viewController: topVC) topItem.minimumThickness = 100 topItem.holdingPriority = .init(240) - let bottomItem = NSSplitViewItem(viewController: bottomContainer) + let bottomItem = NSSplitViewItem(viewController: bottomVC) bottomItem.minimumThickness = 150 bottomItem.holdingPriority = .init(260) bottomItem.canCollapse = true @@ -42,8 +42,8 @@ struct QuerySplitView: NSViewControllerRe splitVC.addSplitViewItem(topItem) splitVC.addSplitViewItem(bottomItem) - context.coordinator.topContainer = topContainer - context.coordinator.bottomContainer = bottomContainer + context.coordinator.topVC = topVC + context.coordinator.bottomVC = bottomVC return splitVC } @@ -55,13 +55,13 @@ struct QuerySplitView: NSViewControllerRe bottomItem.animator().isCollapsed = isBottomCollapsed } - context.coordinator.topContainer?.hostingController.rootView = topContent - context.coordinator.bottomContainer?.hostingController.rootView = bottomContent + context.coordinator.topVC?.update(rootView: topContent) + context.coordinator.bottomVC?.update(rootView: bottomContent) } final class Coordinator: NSObject, NSSplitViewDelegate { - var topContainer: HostingContainerController? - var bottomContainer: HostingContainerController? + var topVC: HostingPaneController? + var bottomVC: HostingPaneController? func splitView(_ splitView: NSSplitView, canCollapseSubview subview: NSView) -> Bool { subview == splitView.subviews.last @@ -69,17 +69,15 @@ struct QuerySplitView: NSViewControllerRe } } -// MARK: - Hosting Container +// MARK: - Hosting Pane Controller -/// Wraps NSHostingController in a plain NSViewController with Auto Layout -/// constraints pinning the hosted SwiftUI view to all edges. This ensures -/// the SwiftUI content fills the NSSplitView pane instead of rendering -/// at its intrinsic size. -final class HostingContainerController: NSViewController { - let hostingController: NSHostingController +/// Uses NSHostingView as the VC's view so NSSplitView's frame-based layout +/// directly drives the SwiftUI content size proposal. +final class HostingPaneController: NSViewController { + private var hostingView: NSHostingView init(rootView: Content) { - self.hostingController = NSHostingController(rootView: rootView) + self.hostingView = NSHostingView(rootView: rootView) super.init(nibName: nil, bundle: nil) } @@ -87,19 +85,10 @@ final class HostingContainerController: NSViewController { required init?(coder: NSCoder) { fatalError() } override func loadView() { - view = NSView() + view = hostingView } - override func viewDidLoad() { - super.viewDidLoad() - addChild(hostingController) - hostingController.view.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(hostingController.view) - NSLayoutConstraint.activate([ - hostingController.view.topAnchor.constraint(equalTo: view.topAnchor), - hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), - hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), - hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), - ]) + func update(rootView: Content) { + hostingView.rootView = rootView } } From 8e408f08ac4e94a95beda3928f4d7cb33d7d57c8 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 30 Mar 2026 18:33:16 +0700 Subject: [PATCH 20/33] fix: embed NSHostingView in container with low compression resistance for split resize --- TablePro/Views/Editor/QuerySplitView.swift | 25 +++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/TablePro/Views/Editor/QuerySplitView.swift b/TablePro/Views/Editor/QuerySplitView.swift index 34d5ee1a..9f49d2b4 100644 --- a/TablePro/Views/Editor/QuerySplitView.swift +++ b/TablePro/Views/Editor/QuerySplitView.swift @@ -71,8 +71,10 @@ struct QuerySplitView: NSViewControllerRe // MARK: - Hosting Pane Controller -/// Uses NSHostingView as the VC's view so NSSplitView's frame-based layout -/// directly drives the SwiftUI content size proposal. +/// Embeds NSHostingView in a plain NSView container. The container uses +/// frame-based layout (for NSSplitView), while the hosting view is pinned +/// to all edges via Auto Layout with low compression resistance so the +/// split view divider can freely resize panes. final class HostingPaneController: NSViewController { private var hostingView: NSHostingView @@ -85,7 +87,24 @@ final class HostingPaneController: NSViewController { required init?(coder: NSCoder) { fatalError() } override func loadView() { - view = hostingView + let container = NSView() + container.autoresizingMask = [.width, .height] + + hostingView.translatesAutoresizingMaskIntoConstraints = false + hostingView.setContentHuggingPriority(.defaultLow - 1, for: .horizontal) + hostingView.setContentHuggingPriority(.defaultLow - 1, for: .vertical) + hostingView.setContentCompressionResistancePriority(.defaultLow - 1, for: .horizontal) + hostingView.setContentCompressionResistancePriority(.defaultLow - 1, for: .vertical) + container.addSubview(hostingView) + + NSLayoutConstraint.activate([ + hostingView.topAnchor.constraint(equalTo: container.topAnchor), + hostingView.bottomAnchor.constraint(equalTo: container.bottomAnchor), + hostingView.leadingAnchor.constraint(equalTo: container.leadingAnchor), + hostingView.trailingAnchor.constraint(equalTo: container.trailingAnchor), + ]) + + view = container } func update(rootView: Content) { From f426597e37cf91c34df9b1cc1e2f2e38e02f17f8 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 30 Mar 2026 18:36:24 +0700 Subject: [PATCH 21/33] fix: use NSHostingView.sizingOptions = [.minSize] to drop intrinsicContentSize constraint --- TablePro/Views/Editor/QuerySplitView.swift | 27 ++++------------------ 1 file changed, 5 insertions(+), 22 deletions(-) diff --git a/TablePro/Views/Editor/QuerySplitView.swift b/TablePro/Views/Editor/QuerySplitView.swift index 9f49d2b4..fcdf41c7 100644 --- a/TablePro/Views/Editor/QuerySplitView.swift +++ b/TablePro/Views/Editor/QuerySplitView.swift @@ -71,15 +71,15 @@ struct QuerySplitView: NSViewControllerRe // MARK: - Hosting Pane Controller -/// Embeds NSHostingView in a plain NSView container. The container uses -/// frame-based layout (for NSSplitView), while the hosting view is pinned -/// to all edges via Auto Layout with low compression resistance so the -/// split view divider can freely resize panes. +/// NSViewController whose view is an NSHostingView with sizingOptions = [.minSize]. +/// Dropping the intrinsicContentSize constraint lets NSSplitView freely resize +/// panes via the divider while SwiftUI content fills the available space. final class HostingPaneController: NSViewController { private var hostingView: NSHostingView init(rootView: Content) { self.hostingView = NSHostingView(rootView: rootView) + self.hostingView.sizingOptions = [.minSize] super.init(nibName: nil, bundle: nil) } @@ -87,24 +87,7 @@ final class HostingPaneController: NSViewController { required init?(coder: NSCoder) { fatalError() } override func loadView() { - let container = NSView() - container.autoresizingMask = [.width, .height] - - hostingView.translatesAutoresizingMaskIntoConstraints = false - hostingView.setContentHuggingPriority(.defaultLow - 1, for: .horizontal) - hostingView.setContentHuggingPriority(.defaultLow - 1, for: .vertical) - hostingView.setContentCompressionResistancePriority(.defaultLow - 1, for: .horizontal) - hostingView.setContentCompressionResistancePriority(.defaultLow - 1, for: .vertical) - container.addSubview(hostingView) - - NSLayoutConstraint.activate([ - hostingView.topAnchor.constraint(equalTo: container.topAnchor), - hostingView.bottomAnchor.constraint(equalTo: container.bottomAnchor), - hostingView.leadingAnchor.constraint(equalTo: container.leadingAnchor), - hostingView.trailingAnchor.constraint(equalTo: container.trailingAnchor), - ]) - - view = container + view = hostingView } func update(rootView: Content) { From fbac9c91349dbfbc43270f281c2c6ab1c86a1337 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 31 Mar 2026 08:13:21 +0700 Subject: [PATCH 22/33] refactor: use NSSplitView directly via NSViewRepresentable instead of NSSplitViewController --- TablePro/Views/Editor/QuerySplitView.swift | 132 +++++++++++---------- 1 file changed, 71 insertions(+), 61 deletions(-) diff --git a/TablePro/Views/Editor/QuerySplitView.swift b/TablePro/Views/Editor/QuerySplitView.swift index fcdf41c7..a6d874b1 100644 --- a/TablePro/Views/Editor/QuerySplitView.swift +++ b/TablePro/Views/Editor/QuerySplitView.swift @@ -2,15 +2,15 @@ // QuerySplitView.swift // TablePro // -// NSSplitViewController wrapper for the query editor / results split. -// Uses NSSplitViewItem.isCollapsed for proper collapse/expand with divider -// position preservation, and autosaveName for cross-session persistence. +// NSSplitView wrapper (NSViewRepresentable) for the query editor / results split. +// Uses autosaveName for divider position persistence and manual collapse via +// subview hiding + adjustSubviews(). // import AppKit import SwiftUI -struct QuerySplitView: NSViewControllerRepresentable { +struct QuerySplitView: NSViewRepresentable { var isBottomCollapsed: Bool var autosaveName: String @ViewBuilder var topContent: TopContent @@ -20,77 +20,87 @@ struct QuerySplitView: NSViewControllerRe Coordinator() } - func makeNSViewController(context: Context) -> NSSplitViewController { - let splitVC = NSSplitViewController() - splitVC.splitView.isVertical = false - splitVC.splitView.dividerStyle = .thin - splitVC.splitView.autosaveName = autosaveName + func makeNSView(context: Context) -> NSSplitView { + let splitView = NSSplitView() + splitView.isVertical = false + splitView.dividerStyle = .thin + splitView.autosaveName = autosaveName + splitView.delegate = context.coordinator - let topVC = HostingPaneController(rootView: topContent) - let bottomVC = HostingPaneController(rootView: bottomContent) + let topHosting = NSHostingView(rootView: topContent) + topHosting.sizingOptions = [.minSize] - let topItem = NSSplitViewItem(viewController: topVC) - topItem.minimumThickness = 100 - topItem.holdingPriority = .init(240) + let bottomHosting = NSHostingView(rootView: bottomContent) + bottomHosting.sizingOptions = [.minSize] - let bottomItem = NSSplitViewItem(viewController: bottomVC) - bottomItem.minimumThickness = 150 - bottomItem.holdingPriority = .init(260) - bottomItem.canCollapse = true - bottomItem.isCollapsed = isBottomCollapsed + splitView.addArrangedSubview(topHosting) + splitView.addArrangedSubview(bottomHosting) - splitVC.addSplitViewItem(topItem) - splitVC.addSplitViewItem(bottomItem) + context.coordinator.topHosting = topHosting + context.coordinator.bottomHosting = bottomHosting + context.coordinator.lastCollapsedState = isBottomCollapsed - context.coordinator.topVC = topVC - context.coordinator.bottomVC = bottomVC + if isBottomCollapsed { + bottomHosting.isHidden = true + } - return splitVC + return splitView } - func updateNSViewController(_ splitVC: NSSplitViewController, context: Context) { - guard splitVC.splitViewItems.count == 2 else { return } - let bottomItem = splitVC.splitViewItems[1] - if bottomItem.isCollapsed != isBottomCollapsed { - bottomItem.animator().isCollapsed = isBottomCollapsed + func updateNSView(_ splitView: NSSplitView, context: Context) { + context.coordinator.topHosting?.rootView = topContent + context.coordinator.bottomHosting?.rootView = bottomContent + + guard let bottomView = context.coordinator.bottomHosting else { return } + let wasCollapsed = context.coordinator.lastCollapsedState + + if isBottomCollapsed != wasCollapsed { + context.coordinator.lastCollapsedState = isBottomCollapsed + if isBottomCollapsed { + // Save divider position before collapsing + if splitView.subviews.count == 2 { + context.coordinator.savedDividerPosition = splitView.subviews[0].frame.height + } + bottomView.isHidden = true + splitView.adjustSubviews() + } else { + bottomView.isHidden = false + splitView.adjustSubviews() + // Restore divider position + if let saved = context.coordinator.savedDividerPosition { + splitView.setPosition(saved, ofDividerAt: 0) + } + } } - - context.coordinator.topVC?.update(rootView: topContent) - context.coordinator.bottomVC?.update(rootView: bottomContent) } final class Coordinator: NSObject, NSSplitViewDelegate { - var topVC: HostingPaneController? - var bottomVC: HostingPaneController? - - func splitView(_ splitView: NSSplitView, canCollapseSubview subview: NSView) -> Bool { - subview == splitView.subviews.last + var topHosting: NSHostingView? + var bottomHosting: NSHostingView? + var lastCollapsedState = false + var savedDividerPosition: CGFloat? + + func splitView( + _ splitView: NSSplitView, + constrainMinCoordinate proposedMinimumPosition: CGFloat, + ofSubviewAt dividerIndex: Int + ) -> CGFloat { + 100 } - } -} - -// MARK: - Hosting Pane Controller - -/// NSViewController whose view is an NSHostingView with sizingOptions = [.minSize]. -/// Dropping the intrinsicContentSize constraint lets NSSplitView freely resize -/// panes via the divider while SwiftUI content fills the available space. -final class HostingPaneController: NSViewController { - private var hostingView: NSHostingView - init(rootView: Content) { - self.hostingView = NSHostingView(rootView: rootView) - self.hostingView.sizingOptions = [.minSize] - super.init(nibName: nil, bundle: nil) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { fatalError() } - - override func loadView() { - view = hostingView - } + func splitView( + _ splitView: NSSplitView, + constrainMaxCoordinate proposedMaximumPosition: CGFloat, + ofSubviewAt dividerIndex: Int + ) -> CGFloat { + splitView.bounds.height - 150 + } - func update(rootView: Content) { - hostingView.rootView = rootView + func splitView( + _ splitView: NSSplitView, + canCollapseSubview subview: NSView + ) -> Bool { + subview == bottomHosting + } } } From 56369f266c11a7617a471ed76e0d61b23f65ae03 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 31 Mar 2026 08:17:03 +0700 Subject: [PATCH 23/33] fix: restore ResultTabBar + InlineErrorBanner in resultsSection, hide divider when collapsed --- TablePro/Views/Editor/QuerySplitView.swift | 20 ++++ .../Main/Child/MainEditorContentView.swift | 102 ++++++++++++++---- 2 files changed, 102 insertions(+), 20 deletions(-) diff --git a/TablePro/Views/Editor/QuerySplitView.swift b/TablePro/Views/Editor/QuerySplitView.swift index a6d874b1..af5486f6 100644 --- a/TablePro/Views/Editor/QuerySplitView.swift +++ b/TablePro/Views/Editor/QuerySplitView.swift @@ -102,5 +102,25 @@ struct QuerySplitView: NSViewRepresentabl ) -> Bool { subview == bottomHosting } + + func splitView( + _ splitView: NSSplitView, + effectiveRect proposedEffectiveRect: NSRect, + forDrawnRect drawnRect: NSRect, + ofDividerAt dividerIndex: Int + ) -> NSRect { + // Hide divider when bottom pane is collapsed + if bottomHosting?.isHidden == true { + return .zero + } + return proposedEffectiveRect + } + + func splitView( + _ splitView: NSSplitView, + shouldHideDividerAt dividerIndex: Int + ) -> Bool { + bottomHosting?.isHidden == true + } } } diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 6c160575..fd197561 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -299,39 +299,101 @@ struct MainEditorContentView: View { .frame(maxHeight: .infinity) } else if let explainText = tab.explainText { ExplainResultView(text: explainText, executionTime: tab.explainExecutionTime) - } else if tab.resultColumns.isEmpty && tab.errorMessage == nil - && tab.lastExecutedAt != nil && !tab.isExecuting - { - QuerySuccessView( - rowsAffected: tab.rowsAffected, - executionTime: tab.executionTime, - statusMessage: tab.statusMessage - ) } 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 } ) - .transition(.move(edge: .top).combined(with: .opacity)) Divider() } - dataGridView(tab: tab) + // 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 + { + ResultSuccessView( + rowsAffected: tab.rowsAffected, + executionTime: tab.executionTime, + statusMessage: tab.statusMessage + ) + } 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 + ) + .transition(.move(edge: .top).combined(with: .opacity)) + Divider() + } + + dataGridView(tab: tab) + } } statusBar(tab: tab) } - .frame(minHeight: 150) + .frame(maxWidth: .infinity, maxHeight: .infinity) .animation(.easeInOut(duration: 0.2), value: filterStateManager.isVisible) .animation(.easeInOut(duration: 0.2), value: tab.errorMessage) } + private func resultTabBar(tab: QueryTab) -> some View { + ResultTabBar( + resultSets: tab.resultSets, + activeResultSetId: Binding( + get: { tab.activeResultSetId }, + set: { newId in + if let tabIdx = coordinator.tabManager.selectedTabIndex { + coordinator.tabManager.tabs[tabIdx].activeResultSetId = newId + } + } + ), + onClose: { id in + guard let tabIdx = coordinator.tabManager.selectedTabIndex else { return } + let rs = coordinator.tabManager.tabs[tabIdx].resultSets.first { $0.id == id } + guard rs?.isPinned != true else { return } + coordinator.tabManager.tabs[tabIdx].resultSets.removeAll { $0.id == id } + if tab.activeResultSetId == id { + coordinator.tabManager.tabs[tabIdx].activeResultSetId = + coordinator.tabManager.tabs[tabIdx].resultSets.last?.id + } + if coordinator.tabManager.tabs[tabIdx].resultSets.isEmpty { + coordinator.tabManager.tabs[tabIdx].resultColumns = [] + coordinator.tabManager.tabs[tabIdx].columnTypes = [] + coordinator.tabManager.tabs[tabIdx].resultRows = [] + coordinator.tabManager.tabs[tabIdx].errorMessage = nil + coordinator.tabManager.tabs[tabIdx].rowsAffected = 0 + coordinator.tabManager.tabs[tabIdx].executionTime = nil + coordinator.tabManager.tabs[tabIdx].statusMessage = nil + } + }, + onPin: { id in + tab.resultSets.first { $0.id == id }?.isPinned.toggle() + } + ) + } + @ViewBuilder private func dataGridView(tab: QueryTab) -> some View { DataGridView( From 53d8504152dee01bee30d5914cd2b7625b25e76e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 31 Mar 2026 08:20:16 +0700 Subject: [PATCH 24/33] fix: move divider to bottom edge before hiding pane and force display to clear divider line --- TablePro/Views/Editor/QuerySplitView.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/TablePro/Views/Editor/QuerySplitView.swift b/TablePro/Views/Editor/QuerySplitView.swift index af5486f6..352d665c 100644 --- a/TablePro/Views/Editor/QuerySplitView.swift +++ b/TablePro/Views/Editor/QuerySplitView.swift @@ -58,11 +58,13 @@ struct QuerySplitView: NSViewRepresentabl context.coordinator.lastCollapsedState = isBottomCollapsed if isBottomCollapsed { // Save divider position before collapsing - if splitView.subviews.count == 2 { + if splitView.subviews.count >= 2 { context.coordinator.savedDividerPosition = splitView.subviews[0].frame.height } + // Move divider to bottom edge to collapse + splitView.setPosition(splitView.bounds.height, ofDividerAt: 0) bottomView.isHidden = true - splitView.adjustSubviews() + splitView.display() } else { bottomView.isHidden = false splitView.adjustSubviews() @@ -70,6 +72,7 @@ struct QuerySplitView: NSViewRepresentabl if let saved = context.coordinator.savedDividerPosition { splitView.setPosition(saved, ofDividerAt: 0) } + splitView.display() } } } From 482e82e440370d7d003f6e1384199d2df6235962 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 31 Mar 2026 08:23:24 +0700 Subject: [PATCH 25/33] fix: read DataGridView data from active ResultSet and invalidate cache on tab switch --- .../Main/Child/MainEditorContentView.swift | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index fd197561..e7723c09 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -161,6 +161,10 @@ struct MainEditorContentView: View { guard let tab = tabManager.selectedTab else { return } cacheRowProvider(for: tab) } + .onChange(of: tabManager.selectedTab?.activeResultSetId) { _, _ in + guard let tab = tabManager.selectedTab else { return } + cacheRowProvider(for: tab) + } } // MARK: - Tab Content @@ -472,7 +476,20 @@ struct MainEditorContentView: View { } private func makeRowProvider(for tab: QueryTab) -> InMemoryRowProvider { - InMemoryRowProvider( + // Use active ResultSet data when available (multi-statement results) + if let rs = tab.activeResultSet, !rs.resultColumns.isEmpty { + return InMemoryRowProvider( + rowBuffer: rs.rowBuffer, + sortIndices: sortIndicesForTab(tab), + columns: rs.resultColumns, + columnDefaults: rs.columnDefaults, + columnTypes: rs.columnTypes, + columnForeignKeys: rs.columnForeignKeys, + columnEnumValues: rs.columnEnumValues, + columnNullable: rs.columnNullable + ) + } + return InMemoryRowProvider( rowBuffer: tab.rowBuffer, sortIndices: sortIndicesForTab(tab), columns: tab.resultColumns, From 99e2f89fd00b45a3d3a100da06c4ca50e56bd886 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 31 Mar 2026 08:25:05 +0700 Subject: [PATCH 26/33] fix: don't show ResultSuccessView after all result tabs are closed --- TablePro/Views/Main/Child/MainEditorContentView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index e7723c09..fd4ceded 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -330,6 +330,7 @@ struct MainEditorContentView: View { ) } else if tab.resultColumns.isEmpty && tab.errorMessage == nil && tab.lastExecutedAt != nil && !tab.isExecuting + && !tab.resultSets.isEmpty { ResultSuccessView( rowsAffected: tab.rowsAffected, From 81c7453c1d2d0c1b6aa17d271d6b7f8606c00cc6 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 31 Mar 2026 08:31:46 +0700 Subject: [PATCH 27/33] fix: clear rowBuffer and bump resultVersion when all result tabs closed --- TablePro/Views/Main/Child/MainEditorContentView.swift | 2 ++ TablePro/Views/Main/MainContentCommandActions.swift | 2 ++ TablePro/Views/Results/ResultsPanelView.swift | 2 ++ 3 files changed, 6 insertions(+) diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index fd4ceded..fa2073a3 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -384,12 +384,14 @@ struct MainEditorContentView: View { coordinator.tabManager.tabs[tabIdx].resultSets.last?.id } if coordinator.tabManager.tabs[tabIdx].resultSets.isEmpty { + coordinator.tabManager.tabs[tabIdx].rowBuffer = RowBuffer() coordinator.tabManager.tabs[tabIdx].resultColumns = [] coordinator.tabManager.tabs[tabIdx].columnTypes = [] coordinator.tabManager.tabs[tabIdx].resultRows = [] coordinator.tabManager.tabs[tabIdx].errorMessage = nil coordinator.tabManager.tabs[tabIdx].rowsAffected = 0 coordinator.tabManager.tabs[tabIdx].executionTime = nil + coordinator.tabManager.tabs[tabIdx].resultVersion += 1 coordinator.tabManager.tabs[tabIdx].statusMessage = nil } }, diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 6ba0be5f..1b3d53f9 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -577,6 +577,7 @@ final class MainContentCommandActions { } // Clear legacy properties when no result sets remain if coordinator?.tabManager.tabs[tabIdx].resultSets.isEmpty == true { + coordinator?.tabManager.tabs[tabIdx].rowBuffer = RowBuffer() coordinator?.tabManager.tabs[tabIdx].resultColumns = [] coordinator?.tabManager.tabs[tabIdx].columnTypes = [] coordinator?.tabManager.tabs[tabIdx].resultRows = [] @@ -584,6 +585,7 @@ final class MainContentCommandActions { coordinator?.tabManager.tabs[tabIdx].rowsAffected = 0 coordinator?.tabManager.tabs[tabIdx].executionTime = nil coordinator?.tabManager.tabs[tabIdx].statusMessage = nil + coordinator?.tabManager.tabs[tabIdx].resultVersion += 1 } } diff --git a/TablePro/Views/Results/ResultsPanelView.swift b/TablePro/Views/Results/ResultsPanelView.swift index 808f5f9b..379559ec 100644 --- a/TablePro/Views/Results/ResultsPanelView.swift +++ b/TablePro/Views/Results/ResultsPanelView.swift @@ -93,6 +93,7 @@ struct ResultsPanelView: View { coord.tabManager.tabs[tabIdx].resultSets.last?.id } if coord.tabManager.tabs[tabIdx].resultSets.isEmpty { + coord.tabManager.tabs[tabIdx].rowBuffer = RowBuffer() coord.tabManager.tabs[tabIdx].resultColumns = [] coord.tabManager.tabs[tabIdx].columnTypes = [] coord.tabManager.tabs[tabIdx].resultRows = [] @@ -100,6 +101,7 @@ struct ResultsPanelView: View { coord.tabManager.tabs[tabIdx].rowsAffected = 0 coord.tabManager.tabs[tabIdx].executionTime = nil coord.tabManager.tabs[tabIdx].statusMessage = nil + coord.tabManager.tabs[tabIdx].resultVersion += 1 } } From 2b33576af94a19e3490ba2642f8ef5b1d4be279d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 31 Mar 2026 08:34:00 +0700 Subject: [PATCH 28/33] fix: show empty state instead of stale DataGridView when all result tabs closed --- TablePro/Views/Main/Child/MainEditorContentView.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index fa2073a3..ca8ea08d 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -337,6 +337,9 @@ struct MainEditorContentView: View { executionTime: tab.executionTime, statusMessage: tab.statusMessage ) + } else if tab.resultColumns.isEmpty && tab.resultSets.isEmpty { + // All result tabs closed — empty grid + Spacer() } else { // Filter panel (collapsible, above data grid) if filterStateManager.isVisible && tab.tabType == .table { From e36b88033de9ad622ab6b9fcfb2efd46c12a5c6a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 31 Mar 2026 08:36:22 +0700 Subject: [PATCH 29/33] feat: auto-collapse results panel when all result tabs are closed --- TablePro/Views/Main/Child/MainEditorContentView.swift | 2 ++ TablePro/Views/Main/MainContentCommandActions.swift | 2 ++ TablePro/Views/Results/ResultsPanelView.swift | 2 ++ 3 files changed, 6 insertions(+) diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index ca8ea08d..2833bde2 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -396,6 +396,8 @@ struct MainEditorContentView: View { coordinator.tabManager.tabs[tabIdx].executionTime = nil coordinator.tabManager.tabs[tabIdx].resultVersion += 1 coordinator.tabManager.tabs[tabIdx].statusMessage = nil + coordinator.tabManager.tabs[tabIdx].isResultsCollapsed = true + coordinator.toolbarState.isResultsCollapsed = true } }, onPin: { id in diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 1b3d53f9..185c9cde 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -586,6 +586,8 @@ final class MainContentCommandActions { coordinator?.tabManager.tabs[tabIdx].executionTime = nil coordinator?.tabManager.tabs[tabIdx].statusMessage = nil coordinator?.tabManager.tabs[tabIdx].resultVersion += 1 + coordinator?.tabManager.tabs[tabIdx].isResultsCollapsed = true + coordinator?.toolbarState.isResultsCollapsed = true } } diff --git a/TablePro/Views/Results/ResultsPanelView.swift b/TablePro/Views/Results/ResultsPanelView.swift index 379559ec..f23b86d8 100644 --- a/TablePro/Views/Results/ResultsPanelView.swift +++ b/TablePro/Views/Results/ResultsPanelView.swift @@ -102,6 +102,8 @@ struct ResultsPanelView: View { coord.tabManager.tabs[tabIdx].executionTime = nil coord.tabManager.tabs[tabIdx].statusMessage = nil coord.tabManager.tabs[tabIdx].resultVersion += 1 + coord.tabManager.tabs[tabIdx].isResultsCollapsed = true + coord.toolbarState.isResultsCollapsed = true } } From 7cece84f615f989c89dc049c269573f63c554ac4 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: Tue, 31 Mar 2026 08:57:50 +0700 Subject: [PATCH 30/33] fix: address all review issues for query results rewrite 1. Delete dead code ResultsPanelView.swift (never integrated) 2. Fix sortIndicesForTab to use active ResultSet data instead of tab-level 3. Change Cmd+[/] to Cmd+Opt+[/] to avoid system navigation conflict 4. Fix ResultSuccessView localization (use String(format:localized:)) 5. Extract closeResultSet(id:) to coordinator, remove 3x duplication 6. Toolbar toggle button icon reflects collapsed/expanded state --- .../xcshareddata/swiftpm/Package.resolved | 11 +- .../Models/UI/KeyboardShortcutModels.swift | 4 +- .../Main/Child/MainEditorContentView.swift | 50 ++++--- ...ainContentCoordinator+SidebarActions.swift | 25 ++++ .../Main/MainContentCommandActions.swift | 28 +--- .../Views/Results/ResultSuccessView.swift | 2 +- TablePro/Views/Results/ResultsPanelView.swift | 123 ------------------ .../Views/Toolbar/TableProToolbarView.swift | 9 +- 8 files changed, 62 insertions(+), 190 deletions(-) delete mode 100644 TablePro/Views/Results/ResultsPanelView.swift diff --git a/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 95e51f59..1f26b30d 100644 --- a/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "a7a6b62d3a1069b1ea8b6d44c1a52d154af36b1945f05d7b91799e978f549468", + "originHash" : "308ffea793d48bfbabf7f6667ff581207e5531743a2a4b6cfdb3e49a56a5857d", "pins" : [ { "identity" : "codeeditsymbols", @@ -10,15 +10,6 @@ "version" : "0.2.3" } }, - { - "identity" : "codeedittextview", - "kind" : "remoteSourceControl", - "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", - "state" : { - "revision" : "d7ac3f11f22ec2e820187acce8f3a3fb7aa8ddec", - "version" : "0.12.1" - } - }, { "identity" : "networkimage", "kind" : "remoteSourceControl", diff --git a/TablePro/Models/UI/KeyboardShortcutModels.swift b/TablePro/Models/UI/KeyboardShortcutModels.swift index 5eb0cd2c..3d938a90 100644 --- a/TablePro/Models/UI/KeyboardShortcutModels.swift +++ b/TablePro/Models/UI/KeyboardShortcutModels.swift @@ -450,8 +450,8 @@ struct KeyboardSettings: Codable, Equatable { .toggleFilters: KeyCombo(key: "f", command: true), .toggleHistory: KeyCombo(key: "y", command: true), .toggleResults: KeyCombo(key: "r", command: true, option: true), - .previousResultTab: KeyCombo(key: "[", command: true), - .nextResultTab: KeyCombo(key: "]", command: true), + .previousResultTab: KeyCombo(key: "[", command: true, option: true), + .nextResultTab: KeyCombo(key: "]", command: true, option: true), .closeResultTab: KeyCombo(key: "w", command: true, shift: true), // Tabs diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 2833bde2..45879bf6 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -378,27 +378,7 @@ struct MainEditorContentView: View { } ), onClose: { id in - guard let tabIdx = coordinator.tabManager.selectedTabIndex else { return } - let rs = coordinator.tabManager.tabs[tabIdx].resultSets.first { $0.id == id } - guard rs?.isPinned != true else { return } - coordinator.tabManager.tabs[tabIdx].resultSets.removeAll { $0.id == id } - if tab.activeResultSetId == id { - coordinator.tabManager.tabs[tabIdx].activeResultSetId = - coordinator.tabManager.tabs[tabIdx].resultSets.last?.id - } - if coordinator.tabManager.tabs[tabIdx].resultSets.isEmpty { - coordinator.tabManager.tabs[tabIdx].rowBuffer = RowBuffer() - coordinator.tabManager.tabs[tabIdx].resultColumns = [] - coordinator.tabManager.tabs[tabIdx].columnTypes = [] - coordinator.tabManager.tabs[tabIdx].resultRows = [] - coordinator.tabManager.tabs[tabIdx].errorMessage = nil - coordinator.tabManager.tabs[tabIdx].rowsAffected = 0 - coordinator.tabManager.tabs[tabIdx].executionTime = nil - coordinator.tabManager.tabs[tabIdx].resultVersion += 1 - coordinator.tabManager.tabs[tabIdx].statusMessage = nil - coordinator.tabManager.tabs[tabIdx].isResultsCollapsed = true - coordinator.toolbarState.isResultsCollapsed = true - } + coordinator.closeResultSet(id: id) }, onPin: { id in tab.resultSets.first { $0.id == id }?.isPinned.toggle() @@ -512,7 +492,21 @@ struct MainEditorContentView: View { /// Returns sort index permutation for a tab, or nil if no sorting is needed. /// For table tabs, sorting is handled server-side via SQL ORDER BY. private func sortIndicesForTab(_ tab: QueryTab) -> [Int]? { - guard !tab.rowBuffer.isEvicted else { return nil } + // Resolve data source: active ResultSet or tab-level fallback + let rowBuffer: RowBuffer + let rows: [[String?]] + let colTypes: [ColumnType] + if let rs = tab.activeResultSet, !rs.resultColumns.isEmpty { + rowBuffer = rs.rowBuffer + rows = rs.resultRows + colTypes = rs.columnTypes + } else { + rowBuffer = tab.rowBuffer + rows = tab.resultRows + colTypes = tab.columnTypes + } + + guard !rowBuffer.isEvicted else { return nil } // Table tabs: no client-side sorting if tab.tabType == .table { @@ -534,7 +528,7 @@ struct MainEditorContentView: View { } // For large datasets sorted async, return nil (unsorted) until cache is ready - if tab.resultRows.count > 10_000 { + if rows.count > 10_000 { return nil } @@ -548,10 +542,10 @@ struct MainEditorContentView: View { } let sortColumns = tab.sortState.columns - let indices = Array(tab.resultRows.indices) + let indices = Array(rows.indices) let sortedIndices = indices.sorted { idx1, idx2 in - let row1 = tab.resultRows[idx1] - let row2 = tab.resultRows[idx2] + let row1 = rows[idx1] + let row2 = rows[idx2] for sortCol in sortColumns { let val1 = sortCol.columnIndex < row1.count @@ -560,8 +554,8 @@ struct MainEditorContentView: View { sortCol.columnIndex < row2.count ? (row2[sortCol.columnIndex] ?? "") : "" let colType = - sortCol.columnIndex < tab.columnTypes.count - ? tab.columnTypes[sortCol.columnIndex] : nil + sortCol.columnIndex < colTypes.count + ? colTypes[sortCol.columnIndex] : nil let result = RowSortComparator.compare(val1, val2, columnType: colType) if result == .orderedSame { continue } return sortCol.direction == .ascending diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift index f15439c9..83096e80 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift @@ -10,6 +10,31 @@ import Foundation import UniformTypeIdentifiers extension MainContentCoordinator { + // MARK: - Result Set Operations + + func closeResultSet(id: UUID) { + guard let tabIdx = tabManager.selectedTabIndex else { return } + let rs = tabManager.tabs[tabIdx].resultSets.first { $0.id == id } + guard rs?.isPinned != true else { return } + tabManager.tabs[tabIdx].resultSets.removeAll { $0.id == id } + if tabManager.tabs[tabIdx].activeResultSetId == id { + tabManager.tabs[tabIdx].activeResultSetId = tabManager.tabs[tabIdx].resultSets.last?.id + } + if tabManager.tabs[tabIdx].resultSets.isEmpty { + tabManager.tabs[tabIdx].rowBuffer = RowBuffer() + tabManager.tabs[tabIdx].resultColumns = [] + tabManager.tabs[tabIdx].columnTypes = [] + tabManager.tabs[tabIdx].resultRows = [] + tabManager.tabs[tabIdx].errorMessage = nil + tabManager.tabs[tabIdx].rowsAffected = 0 + tabManager.tabs[tabIdx].executionTime = nil + tabManager.tabs[tabIdx].statusMessage = nil + tabManager.tabs[tabIdx].resultVersion += 1 + tabManager.tabs[tabIdx].isResultsCollapsed = true + toolbarState.isResultsCollapsed = true + } + } + // MARK: - Table Operations func createNewTable() { diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 185c9cde..1eaa2617 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -565,30 +565,10 @@ final class MainContentCommandActions { } func closeResultTab() { - guard let tabIdx = coordinator?.tabManager.selectedTabIndex else { return } - guard let tabs = coordinator?.tabManager.tabs, !tabs[tabIdx].resultSets.isEmpty else { return } - guard let activeId = tabs[tabIdx].activeResultSetId ?? tabs[tabIdx].resultSets.last?.id else { return } - let rs = tabs[tabIdx].resultSets.first { $0.id == activeId } - guard rs?.isPinned != true else { return } - coordinator?.tabManager.tabs[tabIdx].resultSets.removeAll { $0.id == activeId } - if tabs[tabIdx].activeResultSetId == activeId { - coordinator?.tabManager.tabs[tabIdx].activeResultSetId = - coordinator?.tabManager.tabs[tabIdx].resultSets.last?.id - } - // Clear legacy properties when no result sets remain - if coordinator?.tabManager.tabs[tabIdx].resultSets.isEmpty == true { - coordinator?.tabManager.tabs[tabIdx].rowBuffer = RowBuffer() - coordinator?.tabManager.tabs[tabIdx].resultColumns = [] - coordinator?.tabManager.tabs[tabIdx].columnTypes = [] - coordinator?.tabManager.tabs[tabIdx].resultRows = [] - coordinator?.tabManager.tabs[tabIdx].errorMessage = nil - coordinator?.tabManager.tabs[tabIdx].rowsAffected = 0 - coordinator?.tabManager.tabs[tabIdx].executionTime = nil - coordinator?.tabManager.tabs[tabIdx].statusMessage = nil - coordinator?.tabManager.tabs[tabIdx].resultVersion += 1 - coordinator?.tabManager.tabs[tabIdx].isResultsCollapsed = true - coordinator?.toolbarState.isResultsCollapsed = true - } + guard let coordinator else { return } + let tab = coordinator.tabManager.selectedTab + guard let activeId = tab?.activeResultSetId ?? tab?.resultSets.last?.id else { return } + coordinator.closeResultSet(id: activeId) } // MARK: - Database Operations (Group A — Called Directly) diff --git a/TablePro/Views/Results/ResultSuccessView.swift b/TablePro/Views/Results/ResultSuccessView.swift index 0a6bb2a2..6b4e36f2 100644 --- a/TablePro/Views/Results/ResultSuccessView.swift +++ b/TablePro/Views/Results/ResultSuccessView.swift @@ -19,7 +19,7 @@ struct ResultSuccessView: View { Image(systemName: "checkmark.circle.fill") .font(.system(size: 36)) .foregroundStyle(.green) - Text("\(rowsAffected) row(s) affected") + Text(String(format: String(localized: "%lld row(s) affected"), Int64(rowsAffected))) .font(.system(size: ThemeEngine.shared.activeTheme.typography.body)) if let time = executionTime { Text(String(format: "%.3fs", time)) diff --git a/TablePro/Views/Results/ResultsPanelView.swift b/TablePro/Views/Results/ResultsPanelView.swift deleted file mode 100644 index f23b86d8..00000000 --- a/TablePro/Views/Results/ResultsPanelView.swift +++ /dev/null @@ -1,123 +0,0 @@ -// -// ResultsPanelView.swift -// TablePro -// -// Main container that orchestrates result tab bar, error banners, and result content. -// Will replace resultsSection() in MainEditorContentView in Phase 3. -// - -import SwiftUI - -struct ResultsPanelView: View { - let tab: QueryTab - let connection: DatabaseConnection - var coordinator: MainContentCoordinator? - - // Callbacks matching DataGridView expectations - var onCellEdit: ((Int, Int, String?) -> Void)? - var onDeleteRows: ((Set) -> Void)? - - var body: some View { - VStack(spacing: 0) { - if tab.resultSets.count > 1 { - ResultTabBar( - resultSets: tab.resultSets, - activeResultSetId: activeResultSetBinding, - onClose: closeResultSet, - onPin: togglePin - ) - Divider() - } - - if let error = tab.activeResultSet?.errorMessage { - InlineErrorBanner( - message: error, - onDismiss: { tab.activeResultSet?.errorMessage = nil }, - onAIFix: nil - ) - Divider() - } - - resultContent - - // Note: MainStatusBarView integration happens in Phase 3 - } - } - - @ViewBuilder - private var resultContent: some View { - if let rs = tab.activeResultSet { - if !rs.resultColumns.isEmpty { - // Has data: DataGridView integration happens in Phase 3 - Text(String(format: String(localized: "DataGridView placeholder for: %@"), rs.label)) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if rs.errorMessage == nil { - ResultSuccessView( - rowsAffected: rs.rowsAffected, - executionTime: rs.executionTime, - statusMessage: rs.statusMessage - ) - } - } else { - VStack(spacing: 8) { - Image(systemName: "text.cursor") - .font(.system(size: 32)) - .foregroundStyle(.secondary) - Text("Run a query to see results") - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - } - - private var activeResultSetBinding: Binding { - Binding( - get: { tab.activeResultSetId }, - set: { newId in - if let coord = coordinator, - let tabIdx = coord.tabManager.selectedTabIndex { - coord.tabManager.tabs[tabIdx].activeResultSetId = newId - } - } - ) - } - - private func closeResultSet(_ id: UUID) { - guard let coord = coordinator, - let tabIdx = coord.tabManager.selectedTabIndex else { return } - let rs = coord.tabManager.tabs[tabIdx].resultSets.first { $0.id == id } - guard rs?.isPinned != true else { return } - coord.tabManager.tabs[tabIdx].resultSets.removeAll { $0.id == id } - if tab.activeResultSetId == id { - coord.tabManager.tabs[tabIdx].activeResultSetId = - coord.tabManager.tabs[tabIdx].resultSets.last?.id - } - if coord.tabManager.tabs[tabIdx].resultSets.isEmpty { - coord.tabManager.tabs[tabIdx].rowBuffer = RowBuffer() - coord.tabManager.tabs[tabIdx].resultColumns = [] - coord.tabManager.tabs[tabIdx].columnTypes = [] - coord.tabManager.tabs[tabIdx].resultRows = [] - coord.tabManager.tabs[tabIdx].errorMessage = nil - coord.tabManager.tabs[tabIdx].rowsAffected = 0 - coord.tabManager.tabs[tabIdx].executionTime = nil - coord.tabManager.tabs[tabIdx].statusMessage = nil - coord.tabManager.tabs[tabIdx].resultVersion += 1 - coord.tabManager.tabs[tabIdx].isResultsCollapsed = true - coord.toolbarState.isResultsCollapsed = true - } - } - - private func togglePin(_ id: UUID) { - guard let rs = tab.resultSets.first(where: { $0.id == id }) else { return } - rs.isPinned.toggle() - } -} - -#Preview("No results") { - let tab = QueryTab(title: "Query 1") - ResultsPanelView( - tab: tab, - connection: DatabaseConnection.preview - ) - .frame(width: 600, height: 400) -} diff --git a/TablePro/Views/Toolbar/TableProToolbarView.swift b/TablePro/Views/Toolbar/TableProToolbarView.swift index e52aec77..b150a00d 100644 --- a/TablePro/Views/Toolbar/TableProToolbarView.swift +++ b/TablePro/Views/Toolbar/TableProToolbarView.swift @@ -161,9 +161,14 @@ struct TableProToolbar: ViewModifier { if !state.isTableTab { ToolbarItem(placement: .primaryAction) { Button { actions?.toggleResults() } label: { - Label("Results", systemImage: "rectangle.bottomhalf.inset.filled") + Label( + "Results", + systemImage: state.isResultsCollapsed + ? "rectangle.bottomhalf.inset.filled" + : "rectangle.inset.filled" + ) } - .help("Toggle Results (⌘⌥R)") + .help(String(localized: "Toggle Results (⌘⌥R)")) .disabled(state.connectionState != .connected) } } From 24efe135b00e71cf1284b4337d52e990a4e2d2c4 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: Tue, 31 Mar 2026 09:08:07 +0700 Subject: [PATCH 31/33] fix: architecture + UIUX review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Pin toggle routes through coordinator + bumps resultVersion for SwiftUI reactivity (was mutating class directly, bypassing Equatable) 2. Remove dead onAIFix API from InlineErrorBanner 3. Use NSColor.selectedControlColor for active tab (was wrong .selectedContentBackgroundColor.opacity(0.3), broken in dark mode) 4. ResultTabBar height 28→32pt (HIG minimum), replace deprecated .cornerRadius with RoundedRectangle, localize context menu strings 5. Consolidate dual ResultSuccessView paths into single branch --- .../Main/Child/MainEditorContentView.swift | 22 ++++++++++--------- .../Views/Results/InlineErrorBanner.swift | 21 ++++-------------- TablePro/Views/Results/ResultTabBar.swift | 21 ++++++++++-------- 3 files changed, 28 insertions(+), 36 deletions(-) diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 45879bf6..0db40e6a 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -330,16 +330,16 @@ struct MainEditorContentView: View { ) } else if tab.resultColumns.isEmpty && tab.errorMessage == nil && tab.lastExecutedAt != nil && !tab.isExecuting - && !tab.resultSets.isEmpty { - ResultSuccessView( - rowsAffected: tab.rowsAffected, - executionTime: tab.executionTime, - statusMessage: tab.statusMessage - ) - } else if tab.resultColumns.isEmpty && tab.resultSets.isEmpty { - // All result tabs closed — empty grid - Spacer() + if tab.resultSets.isEmpty { + Spacer() + } else { + ResultSuccessView( + rowsAffected: tab.rowsAffected, + executionTime: tab.executionTime, + statusMessage: tab.statusMessage + ) + } } else { // Filter panel (collapsible, above data grid) if filterStateManager.isVisible && tab.tabType == .table { @@ -381,7 +381,9 @@ struct MainEditorContentView: View { coordinator.closeResultSet(id: id) }, onPin: { id in - tab.resultSets.first { $0.id == id }?.isPinned.toggle() + guard let tabIdx = coordinator.tabManager.selectedTabIndex else { return } + coordinator.tabManager.tabs[tabIdx].resultSets.first { $0.id == id }?.isPinned.toggle() + coordinator.tabManager.tabs[tabIdx].resultVersion += 1 } ) } diff --git a/TablePro/Views/Results/InlineErrorBanner.swift b/TablePro/Views/Results/InlineErrorBanner.swift index bf19e141..96da638c 100644 --- a/TablePro/Views/Results/InlineErrorBanner.swift +++ b/TablePro/Views/Results/InlineErrorBanner.swift @@ -10,7 +10,6 @@ import SwiftUI struct InlineErrorBanner: View { let message: String var onDismiss: (() -> Void)? - var onAIFix: (() -> Void)? var body: some View { HStack(spacing: 8) { @@ -21,11 +20,6 @@ struct InlineErrorBanner: View { .lineLimit(3) .textSelection(.enabled) Spacer() - if let onAIFix { - Button("Fix with AI") { onAIFix() } - .buttonStyle(.bordered) - .controlSize(.small) - } if let onDismiss { Button { onDismiss() } label: { Image(systemName: "xmark") @@ -41,16 +35,9 @@ struct InlineErrorBanner: View { } #Preview { - VStack { - InlineErrorBanner( - message: "ERROR 1064 (42000): You have an error in your SQL syntax", - onDismiss: {}, - onAIFix: {} - ) - InlineErrorBanner( - message: "Connection refused", - onDismiss: {} - ) - } + InlineErrorBanner( + message: "ERROR 1064 (42000): You have an error in your SQL syntax", + onDismiss: {} + ) .frame(width: 600) } diff --git a/TablePro/Views/Results/ResultTabBar.swift b/TablePro/Views/Results/ResultTabBar.swift index b67c4208..99fb65ee 100644 --- a/TablePro/Views/Results/ResultTabBar.swift +++ b/TablePro/Views/Results/ResultTabBar.swift @@ -22,7 +22,7 @@ struct ResultTabBar: View { } } } - .frame(height: 28) + .frame(height: 32) .background(Color(nsColor: .controlBackgroundColor)) } @@ -31,7 +31,7 @@ struct ResultTabBar: View { return HStack(spacing: 4) { if rs.isPinned { Image(systemName: "pin.fill") - .font(.system(size: 8)) + .font(.system(size: 9)) .foregroundStyle(.secondary) } Text(rs.label) @@ -47,17 +47,21 @@ struct ResultTabBar: View { } } .padding(.horizontal, 10) - .padding(.vertical, 4) - .background(isActive ? Color(nsColor: .selectedContentBackgroundColor).opacity(0.3) : Color.clear) - .cornerRadius(4) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(isActive ? Color(nsColor: .selectedControlColor) : Color.clear) + ) .contentShape(Rectangle()) .onTapGesture { activeResultSetId = rs.id } .contextMenu { - Button(rs.isPinned ? "Unpin" : "Pin Result") { onPin?(rs.id) } + Button(rs.isPinned ? String(localized: "Unpin") : String(localized: "Pin Result")) { + onPin?(rs.id) + } Divider() - Button("Close") { onClose?(rs.id) } + Button(String(localized: "Close")) { onClose?(rs.id) } .disabled(rs.isPinned) - Button("Close Others") { + Button(String(localized: "Close Others")) { for other in resultSets where other.id != rs.id && !other.isPinned { onClose?(other.id) } @@ -65,4 +69,3 @@ struct ResultTabBar: View { } } } - From a6f71fd5480bbe262215b2cf0185d671a2939c3e 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: Tue, 31 Mar 2026 09:14:43 +0700 Subject: [PATCH 32/33] docs: update keyboard shortcuts, sql-editor, and CHANGELOG for query results rewrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix Previous/Next Result shortcuts: Cmd+[ → Cmd+Opt+[ (matches code) - Add sql-editor.mdx sections: collapsible panel, multi-result tabs, pinning, inline errors, non-SELECT success view - Expand CHANGELOG [Unreleased] with all PR #512 features --- CHANGELOG.md | 6 +++++- docs/features/keyboard-shortcuts.mdx | 4 ++-- docs/features/sql-editor.mdx | 20 ++++++++++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cc20e8f..2d8e3a47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Real-time SQL preview with syntax highlighting for CREATE TABLE DDL - Multi-database CREATE TABLE support: MySQL, PostgreSQL, SQLite, SQL Server, ClickHouse, DuckDB - Auto-fit column width: double-click column divider, right-click header → "Size to Fit" / "Size All Columns to Fit" -- Close Result Tab shortcut (`Cmd+Shift+W`) to close the active result tab when multiple are open +- Collapsible results panel — toggle with `Cmd+Opt+R` or toolbar button, auto-expands on query execution +- Multiple result set tabs for multi-statement queries — each statement gets its own tab +- Result tab pinning — right-click → "Pin Result" to preserve from overwrite +- Inline error banner for query errors (replaces modal alert) +- Keyboard shortcuts: Toggle Results (`Cmd+Opt+R`), Previous/Next Result (`Cmd+Opt+[`/`Cmd+Opt+]`), Close Result Tab (`Cmd+Shift+W`) ### Fixed diff --git a/docs/features/keyboard-shortcuts.mdx b/docs/features/keyboard-shortcuts.mdx index ad1366bc..90a17b3a 100644 --- a/docs/features/keyboard-shortcuts.mdx +++ b/docs/features/keyboard-shortcuts.mdx @@ -186,8 +186,8 @@ TablePro is keyboard-driven. Most actions have shortcuts, and most menu shortcut | Action | Shortcut | |--------|----------| -| Previous Result | `Cmd+[` | -| Next Result | `Cmd+]` | +| Previous Result | `Cmd+Opt+[` | +| Next Result | `Cmd+Opt+]` | | Close Result Tab | `Cmd+Shift+W` | ### AI diff --git a/docs/features/sql-editor.mdx b/docs/features/sql-editor.mdx index 9ec9604c..5fe97b98 100644 --- a/docs/features/sql-editor.mdx +++ b/docs/features/sql-editor.mdx @@ -130,6 +130,26 @@ Keywords are context-sensitive: Results appear in the data grid below the editor with row count and execution time. Large result sets are paginated. +#### Collapsible Results Panel + +Toggle the results panel with `Cmd+Opt+R` or the toolbar button to give the editor full height. The panel auto-expands when a new query executes. + +#### Multiple Result Tabs + +When running multiple statements separated by `;`, each statement produces its own result tab. Switch between tabs by clicking or with `Cmd+Opt+[` / `Cmd+Opt+]`. Close a result tab with `Cmd+Shift+W`. + +#### Pinning Results + +Right-click a result tab and select **Pin Result** to preserve it from being overwritten on the next query execution. Pinned tabs stay until explicitly unpinned or closed. + +#### Inline Errors + +Query errors display as a red banner directly above the results area, showing the database error message. Dismiss with the close button. + +#### Non-SELECT Queries + +INSERT, UPDATE, DELETE, and DDL statements show a compact success view with affected row count and execution time instead of an empty grid. + ### Explain Query Press `Option+Cmd+E` to view the execution plan. Shows index usage, join strategies, and estimated row counts. TablePro uses the correct syntax per database (`EXPLAIN` for MySQL/PostgreSQL, `EXPLAIN QUERY PLAN` for SQLite). From 40b5f0d7da1b2dfd4ae6673b9268f96c390fdc4a 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: Tue, 31 Mar 2026 09:15:27 +0700 Subject: [PATCH 33/33] docs: simplify CHANGELOG entries --- CHANGELOG.md | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d8e3a47..d750cbd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,15 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Visual Create Table UI with column, index, and foreign key editors (sidebar → "Create New Table...") -- Real-time SQL preview with syntax highlighting for CREATE TABLE DDL -- Multi-database CREATE TABLE support: MySQL, PostgreSQL, SQLite, SQL Server, ClickHouse, DuckDB -- Auto-fit column width: double-click column divider, right-click header → "Size to Fit" / "Size All Columns to Fit" -- Collapsible results panel — toggle with `Cmd+Opt+R` or toolbar button, auto-expands on query execution -- Multiple result set tabs for multi-statement queries — each statement gets its own tab -- Result tab pinning — right-click → "Pin Result" to preserve from overwrite -- Inline error banner for query errors (replaces modal alert) -- Keyboard shortcuts: Toggle Results (`Cmd+Opt+R`), Previous/Next Result (`Cmd+Opt+[`/`Cmd+Opt+]`), Close Result Tab (`Cmd+Shift+W`) +- Visual Create Table UI with multi-database support (sidebar → "Create New Table...") +- Auto-fit column width: double-click column divider or right-click → "Size to Fit" +- Collapsible results panel (`Cmd+Opt+R`), multiple result tabs for multi-statement queries, result pinning +- Inline error banner for query errors ### Fixed