diff --git a/CHANGELOG.md b/CHANGELOG.md index 672ea3a8a..d750cbd3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +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" +- 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 diff --git a/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 95e51f591..1f26b30da 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/Connection/ConnectionToolbarState.swift b/TablePro/Models/Connection/ConnectionToolbarState.swift index 17964dbca..667213d5c 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/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index a0caa05ed..260a25731 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 000000000..61e7450a8 --- /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/Models/UI/KeyboardShortcutModels.swift b/TablePro/Models/UI/KeyboardShortcutModels.swift index 208dfa772..3d938a90f 100644 --- a/TablePro/Models/UI/KeyboardShortcutModels.swift +++ b/TablePro/Models/UI/KeyboardShortcutModels.swift @@ -71,6 +71,10 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case toggleInspector case toggleFilters case toggleHistory + case toggleResults + case previousResultTab + case nextResultTab + case closeResultTab // Tabs case showPreviousTabBrackets @@ -94,7 +98,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, .closeResultTab: return .view case .showPreviousTabBrackets, .showNextTabBrackets, .previousTabArrows, .nextTabArrows: @@ -137,6 +142,10 @@ 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 .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)") @@ -440,6 +449,10 @@ 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, option: true), + .nextResultTab: KeyCombo(key: "]", command: true, option: 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 e164e87ef..0454ccbb9 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 3a7ba328d..72fa4a771 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -367,6 +367,32 @@ 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) + + 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/Editor/QuerySplitView.swift b/TablePro/Views/Editor/QuerySplitView.swift new file mode 100644 index 000000000..352d665ce --- /dev/null +++ b/TablePro/Views/Editor/QuerySplitView.swift @@ -0,0 +1,129 @@ +// +// QuerySplitView.swift +// TablePro +// +// 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: NSViewRepresentable { + var isBottomCollapsed: Bool + var autosaveName: String + @ViewBuilder var topContent: TopContent + @ViewBuilder var bottomContent: BottomContent + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeNSView(context: Context) -> NSSplitView { + let splitView = NSSplitView() + splitView.isVertical = false + splitView.dividerStyle = .thin + splitView.autosaveName = autosaveName + splitView.delegate = context.coordinator + + let topHosting = NSHostingView(rootView: topContent) + topHosting.sizingOptions = [.minSize] + + let bottomHosting = NSHostingView(rootView: bottomContent) + bottomHosting.sizingOptions = [.minSize] + + splitView.addArrangedSubview(topHosting) + splitView.addArrangedSubview(bottomHosting) + + context.coordinator.topHosting = topHosting + context.coordinator.bottomHosting = bottomHosting + context.coordinator.lastCollapsedState = isBottomCollapsed + + if isBottomCollapsed { + bottomHosting.isHidden = true + } + + return splitView + } + + 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 + } + // Move divider to bottom edge to collapse + splitView.setPosition(splitView.bounds.height, ofDividerAt: 0) + bottomView.isHidden = true + splitView.display() + } else { + bottomView.isHidden = false + splitView.adjustSubviews() + // Restore divider position + if let saved = context.coordinator.savedDividerPosition { + splitView.setPosition(saved, ofDividerAt: 0) + } + splitView.display() + } + } + } + + final class Coordinator: NSObject, NSSplitViewDelegate { + var topHosting: NSHostingView? + var bottomHosting: NSHostingView? + var lastCollapsedState = false + var savedDividerPosition: CGFloat? + + func splitView( + _ splitView: NSSplitView, + constrainMinCoordinate proposedMinimumPosition: CGFloat, + ofSubviewAt dividerIndex: Int + ) -> CGFloat { + 100 + } + + func splitView( + _ splitView: NSSplitView, + constrainMaxCoordinate proposedMaximumPosition: CGFloat, + ofSubviewAt dividerIndex: Int + ) -> CGFloat { + splitView.bounds.height - 150 + } + + func splitView( + _ splitView: NSSplitView, + canCollapseSubview subview: NSView + ) -> 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 91c108b96..0db40e6af 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 @@ -185,47 +189,50 @@ 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(maxWidth: .infinity, maxHeight: .infinity) + }, + bottomContent: { + resultsSection(tab: tab) + .frame(maxWidth: .infinity, maxHeight: .infinity) } - .frame(minHeight: 100, idealHeight: 200) - - // Results (bottom) - resultsSection(tab: tab) - .frame(minHeight: 150) - } + ) } private func updateHasQueryText() { @@ -296,39 +303,91 @@ 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 + { + 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 { + 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 + coordinator.closeResultSet(id: id) + }, + onPin: { id in + 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 + } + ) + } + @ViewBuilder private func dataGridView(tab: QueryTab) -> some View { DataGridView( @@ -407,7 +466,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, @@ -422,7 +494,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 { @@ -444,7 +530,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 } @@ -458,10 +544,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 @@ -470,8 +556,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+MultiStatement.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift index d8d640195..c9664a560 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,6 +149,16 @@ extension MainContentCoordinator { updatedTab.isExecuting = false updatedTab.lastExecutedAt = Date() updatedTab.errorMessage = nil + + // 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 + } + toolbarState.isResultsCollapsed = false + tabManager.tabs[idx] = updatedTab if tabManager.selectedTabId == tabId { @@ -151,6 +178,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) @@ -160,6 +192,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 c35dc61b2..b84663712 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift @@ -157,6 +157,33 @@ extension MainContentCoordinator { updatedTab.metadataVersion += 1 } + // 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 + } + toolbarState.isResultsCollapsed = false + tabManager.tabs[idx] = updatedTab // Cache column types for selective queries on subsequent page/filter/sort reloads. diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift index f15439c9b..83096e809 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/Extensions/MainContentCoordinator+TabSwitch.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift index 6ce8e2528..1ef598561 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 288b1d8ca..1eaa26173 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -538,6 +538,39 @@ final class MainContentCommandActions { rightPanelState.isPresented.toggle() } + 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() { + 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 + } + + func closeResultTab() { + 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) func openDatabaseSwitcher() { diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift new file mode 100644 index 000000000..827b159d7 --- /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 3a87fa46f..ad28f3dcf 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 diff --git a/TablePro/Views/Results/InlineErrorBanner.swift b/TablePro/Views/Results/InlineErrorBanner.swift new file mode 100644 index 000000000..96da638cc --- /dev/null +++ b/TablePro/Views/Results/InlineErrorBanner.swift @@ -0,0 +1,43 @@ +// +// 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 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 onDismiss { + Button { onDismiss() } label: { + Image(systemName: "xmark") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.red.opacity(0.08)) + } +} + +#Preview { + InlineErrorBanner( + message: "ERROR 1064 (42000): You have an error in your SQL syntax", + onDismiss: {} + ) + .frame(width: 600) +} diff --git a/TablePro/Views/Results/ResultSuccessView.swift b/TablePro/Views/Results/ResultSuccessView.swift new file mode 100644 index 000000000..6b4e36f2d --- /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(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)) + .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 000000000..99fb65ee9 --- /dev/null +++ b/TablePro/Views/Results/ResultTabBar.swift @@ -0,0 +1,71 @@ +// +// 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: 32) + .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: 9)) + .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, 6) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(isActive ? Color(nsColor: .selectedControlColor) : Color.clear) + ) + .contentShape(Rectangle()) + .onTapGesture { activeResultSetId = rs.id } + .contextMenu { + Button(rs.isPinned ? String(localized: "Unpin") : String(localized: "Pin Result")) { + onPin?(rs.id) + } + Divider() + Button(String(localized: "Close")) { onClose?(rs.id) } + .disabled(rs.isPinned) + Button(String(localized: "Close Others")) { + for other in resultSets where other.id != rs.id && !other.isPinned { + onClose?(other.id) + } + } + } + } +} diff --git a/TablePro/Views/Toolbar/TableProToolbarView.swift b/TablePro/Views/Toolbar/TableProToolbarView.swift index 8f9b90ba6..b150a00d8 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,6 +158,21 @@ struct TableProToolbar: ViewModifier { } } + if !state.isTableTab { + ToolbarItem(placement: .primaryAction) { + Button { actions?.toggleResults() } label: { + Label( + "Results", + systemImage: state.isResultsCollapsed + ? "rectangle.bottomhalf.inset.filled" + : "rectangle.inset.filled" + ) + } + .help(String(localized: "Toggle Results (⌘⌥R)")) + .disabled(state.connectionState != .connected) + } + } + ToolbarItem(placement: .primaryAction) { Button { actions?.toggleRightSidebar() diff --git a/docs/features/keyboard-shortcuts.mdx b/docs/features/keyboard-shortcuts.mdx index 99c627df7..90a17b3a1 100644 --- a/docs/features/keyboard-shortcuts.mdx +++ b/docs/features/keyboard-shortcuts.mdx @@ -178,9 +178,18 @@ 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+Opt+[` | +| Next Result | `Cmd+Opt+]` | +| Close Result Tab | `Cmd+Shift+W` | + ### AI | Action | Shortcut | Description | diff --git a/docs/features/sql-editor.mdx b/docs/features/sql-editor.mdx index 9ec9604cc..5fe97b984 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).