From 7d6808f8881bcb05ad2e262058832678205662ee Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 12 Jul 2026 20:34:10 +0700 Subject: [PATCH] fix(editor): make result pinning reachable for a single query result (#1855) --- CHANGELOG.md | 5 + .../QueryExecutionCoordinator+Helpers.swift | 4 +- ...yExecutionCoordinator+MultiStatement.swift | 4 +- ...QueryExecutionCoordinator+Parameters.swift | 4 +- TablePro/Models/Query/QueryTabManager.swift | 2 + TablePro/Models/Query/QueryTabState.swift | 14 ++ .../Models/UI/KeyboardShortcutModels.swift | 7 +- TablePro/TableProApp.swift | 9 + .../Main/Child/MainEditorContentView.swift | 10 +- .../MainContentCoordinator+Navigation.swift | 3 +- ...ainContentCoordinator+SidebarActions.swift | 26 +- .../Main/MainContentCommandActions.swift | 14 ++ TablePro/Views/Results/ResultTabBar.swift | 34 ++- .../Views/Main/ResultPinningTests.swift | 231 ++++++++++++++++++ docs/features/keyboard-shortcuts.mdx | 1 + docs/features/sql-editor.mdx | 6 +- 16 files changed, 351 insertions(+), 23 deletions(-) create mode 100644 TableProTests/Views/Main/ResultPinningTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cf4b0609..d0fd534f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,9 +14,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The Favorites sidebar **+** menu now includes **New Query**, which opens an empty SQL query tab. - Manage database users, roles, and privileges on MySQL and PostgreSQL connections. Open **View > Users & Roles** to see the accounts on the server, pick an object from the server down through databases, schemas, tables, and columns, and grant or revoke privileges on it. The privilege list shows where access actually comes from, including privileges inherited from a role or from a parent object. Changes are staged, undoable with ⌘Z, and shown as the exact SQL before they run. (#1413) +### Changed + +- Query results now always show a result tab, so a single result can be pinned before the next query replaces it. Pinning was previously only reachable after running several statements at once. Pin from the tab's context menu or with `Cmd+Option+P`, and hover a tab to see the query that produced it. (#1855) + ### Fixed - A failed or cancelled connection that uses a Cloudflare tunnel no longer leaves the `cloudflared` process running in the background. +- Pinned results are no longer discarded by **Clear Results**, and a tab holding one is no longer reused to browse a different table. (#1855) ## [0.56.2] - 2026-07-10 diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index 12d64c274..1b0a1b70c 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -152,9 +152,7 @@ extension QueryExecutionCoordinator { rs.isTruncated = isTruncated rs.baseQuery = sql - let pinned = tab.display.resultSets.filter(\.isPinned) - tab.display.resultSets = pinned + [rs] - tab.display.activeResultSetId = rs.id + tab.display.replaceUnpinnedResults(with: [rs]) if isTruncated { tab.pagination.hasMoreRows = true diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+MultiStatement.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+MultiStatement.swift index 4cb9e652f..3cce35618 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+MultiStatement.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+MultiStatement.swift @@ -131,9 +131,7 @@ extension QueryExecutionCoordinator { tab.execution.lastExecutedAt = Date() tab.execution.errorMessage = nil - let pinnedResults = tab.display.resultSets.filter(\.isPinned) - tab.display.resultSets = pinnedResults + newResultSets - tab.display.activeResultSetId = newResultSets.last?.id + tab.display.replaceUnpinnedResults(with: newResultSets) if tab.display.isResultsCollapsed { tab.display.isResultsCollapsed = false } diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift index 781c43da4..22bc9a44d 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift @@ -448,9 +448,7 @@ extension QueryExecutionCoordinator { tab.execution.isExecuting = false tab.execution.executionTime = cumulativeTime - let pinnedResults = tab.display.resultSets.filter(\.isPinned) - tab.display.resultSets = pinnedResults + capturedResultSets - tab.display.activeResultSetId = capturedResultSets.last?.id + tab.display.replaceUnpinnedResults(with: capturedResultSets) } let rawSQL = failedStatement diff --git a/TablePro/Models/Query/QueryTabManager.swift b/TablePro/Models/Query/QueryTabManager.swift index 05e64608f..ef6a84db6 100644 --- a/TablePro/Models/Query/QueryTabManager.swift +++ b/TablePro/Models/Query/QueryTabManager.swift @@ -334,6 +334,8 @@ final class QueryTabManager { tab.execution.errorMessage = nil tab.execution.lastExecutedAt = nil tab.display.resultsViewMode = .data + tab.display.resultSets = [] + tab.display.activeResultSetId = nil tab.sortState = SortState() tab.selectedRowIndices = [] tab.pendingChanges = TabChangeSnapshot() diff --git a/TablePro/Models/Query/QueryTabState.swift b/TablePro/Models/Query/QueryTabState.swift index 57a3e6864..c5c8a83da 100644 --- a/TablePro/Models/Query/QueryTabState.swift +++ b/TablePro/Models/Query/QueryTabState.swift @@ -456,6 +456,20 @@ struct TabDisplayState: Equatable { return resultSets.first { $0.id == id } } + var hasPinnedResults: Bool { + resultSets.contains(where: \.isPinned) + } + + mutating func replaceUnpinnedResults(with newResults: [ResultSet]) { + resultSets = resultSets.filter(\.isPinned) + newResults + activeResultSetId = newResults.last?.id ?? resultSets.last?.id + } + + mutating func removeUnpinnedResults() { + resultSets = resultSets.filter(\.isPinned) + activeResultSetId = resultSets.last?.id + } + static func == (lhs: TabDisplayState, rhs: TabDisplayState) -> Bool { lhs.resultsViewMode == rhs.resultsViewMode && lhs.isResultsCollapsed == rhs.isResultsCollapsed diff --git a/TablePro/Models/UI/KeyboardShortcutModels.swift b/TablePro/Models/UI/KeyboardShortcutModels.swift index f128e2240..d3cbff4de 100644 --- a/TablePro/Models/UI/KeyboardShortcutModels.swift +++ b/TablePro/Models/UI/KeyboardShortcutModels.swift @@ -117,6 +117,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case toggleResults case previousResultTab case nextResultTab + case pinResultTab case closeResultTab case focusSidebarSearch case showSidebarTables @@ -141,8 +142,8 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { return .dataGrid case .newTab, .closeTab, .quickSwitcher, .toggleTableBrowser, .toggleInspector, .toggleFilters, .toggleHistory, .toggleResults, .previousResultTab, - .nextResultTab, .closeResultTab, .focusSidebarSearch, .showSidebarTables, - .showSidebarFavorites, .showPreviousTab, .showNextTab: + .nextResultTab, .pinResultTab, .closeResultTab, .focusSidebarSearch, + .showSidebarTables, .showSidebarFavorites, .showPreviousTab, .showNextTab: return .navigation } } @@ -222,6 +223,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 .pinResultTab: return String(localized: "Pin Result") case .closeResultTab: return String(localized: "Close Result Tab") case .focusSidebarSearch: return String(localized: "Focus Sidebar Filter") case .showSidebarTables: return String(localized: "Show Tables Sidebar") @@ -448,6 +450,7 @@ struct KeyboardSettings: Codable, Equatable { .toggleResults: .character("r", command: true, option: true), .previousResultTab: .character("[", command: true, option: true), .nextResultTab: .character("]", command: true, option: true), + .pinResultTab: .character("p", command: true, option: true), .closeResultTab: .character("w", command: true, shift: true), .focusSidebarSearch: .character("f", command: true, option: true), .showSidebarTables: .character("1", command: true, option: true), diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index 74b6f6cc1..853d16df3 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -674,6 +674,15 @@ struct AppMenuCommands: Commands { .optionalKeyboardShortcut(shortcut(for: .nextResultTab)) .disabled(!(actions?.isConnected ?? false)) + Button(actions?.isResultTabPinned == true + ? String(localized: "Unpin Result") + : String(localized: "Pin Result")) + { + actions?.pinResultTab() + } + .optionalKeyboardShortcut(shortcut(for: .pinResultTab)) + .disabled(!(actions?.canPinResultTab ?? false)) + Button("Close Result Tab") { actions?.closeResultTab() } diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index d44724420..b909f08c4 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -548,7 +548,7 @@ struct MainEditorContentView: View { ExplainResultView(text: explainText, executionTime: tab.display.explainExecutionTime, plan: tab.display.explainPlan) .frame(maxWidth: .infinity, maxHeight: .infinity) } else { - if tab.display.resultSets.count > 1 { + if showsResultTabBar(for: tab) { resultTabBar(tab: tab) Divider() } @@ -612,6 +612,11 @@ struct MainEditorContentView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } + private func showsResultTabBar(for tab: QueryTab) -> Bool { + if tab.display.resultSets.count > 1 { return true } + return tab.tabType == .query && !tab.display.resultSets.isEmpty + } + private func resultTabBar(tab: QueryTab) -> some View { ResultTabBar( resultSets: tab.display.resultSets, @@ -625,8 +630,7 @@ struct MainEditorContentView: View { coordinator.closeResultSet(id: id) }, onPin: { id in - guard let tabIdx = coordinator.tabManager.selectedTabIndex else { return } - coordinator.tabManager.tabs[tabIdx].display.resultSets.first { $0.id == id }?.isPinned.toggle() + coordinator.togglePinResultSet(id: id) } ) } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 6f05e0f5e..245fab546 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -305,7 +305,8 @@ extension MainContentCoordinator { guard let tab = tabManager.selectedTab else { return false } if changeManager.hasChanges || selectedTabFilterState.hasAppliedFilters - || tab.hasUserActiveSort { + || tab.hasUserActiveSort + || tab.display.hasPinnedResults { return false } if tab.tabType == .createTable { return !toolbarState.hasCreateTablePending } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift index 21519c3f9..45050b016 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift @@ -13,6 +13,22 @@ import UniformTypeIdentifiers extension MainContentCoordinator { // MARK: - Result Set Operations + var canPinActiveResultSet: Bool { + guard let tab = tabManager.selectedTab, tab.tabType == .query else { return false } + return tab.display.activeResultSet != nil + } + + var isActiveResultSetPinned: Bool { + tabManager.selectedTab?.display.activeResultSet?.isPinned == true + } + + func togglePinResultSet(id: UUID) { + guard let tabIdx = tabManager.selectedTabIndex, + let resultSet = tabManager.tabs[tabIdx].display.resultSets.first(where: { $0.id == id }) + else { return } + resultSet.isPinned.toggle() + } + func closeResultSet(id: UUID) { guard let tabIdx = tabManager.selectedTabIndex else { return } let rs = tabManager.tabs[tabIdx].display.resultSets.first { $0.id == id } @@ -45,10 +61,16 @@ extension MainContentCoordinator { func clearActiveQueryResults() { guard let tabIdx = tabManager.selectedTabIndex else { return } let tabId = tabManager.tabs[tabIdx].id + + if let lastPinned = tabManager.tabs[tabIdx].display.resultSets.last(where: \.isPinned) { + switchActiveResultSet(to: lastPinned.id, in: tabId) + tabManager.mutate(at: tabIdx) { $0.display.removeUnpinnedResults() } + return + } + setActiveTableRows(TableRows(), for: tabId) tabManager.mutate(at: tabIdx) { tab in - tab.display.resultSets = [] - tab.display.activeResultSetId = nil + tab.display.removeUnpinnedResults() tab.execution.errorMessage = nil tab.execution.rowsAffected = 0 tab.execution.executionTime = nil diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index da2a47653..adfa59887 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -882,6 +882,20 @@ final class MainContentCommandActions { coordinator.switchActiveResultSet(to: tab.display.resultSets[currentIndex + 1].id, in: tab.id) } + var canPinResultTab: Bool { + coordinator?.canPinActiveResultSet ?? false + } + + var isResultTabPinned: Bool { + coordinator?.isActiveResultSetPinned ?? false + } + + func pinResultTab() { + guard let coordinator, + let activeId = coordinator.tabManager.selectedTab?.display.activeResultSet?.id else { return } + coordinator.togglePinResultSet(id: activeId) + } + func closeResultTab() { guard let coordinator else { return } let tab = coordinator.tabManager.selectedTab diff --git a/TablePro/Views/Results/ResultTabBar.swift b/TablePro/Views/Results/ResultTabBar.swift index ff3df0cb8..952287966 100644 --- a/TablePro/Views/Results/ResultTabBar.swift +++ b/TablePro/Views/Results/ResultTabBar.swift @@ -2,8 +2,9 @@ // ResultTabBar.swift // TablePro // -// Horizontal tab bar for switching between multiple result sets. -// Only shown when a query produces 2+ result sets. +// Horizontal tab bar for switching between result sets. +// Shown for every query result so a single result can be pinned before the +// next execution replaces it. Pinned results are never an execution target. // import SwiftUI @@ -36,8 +37,9 @@ struct ResultTabBar: View { onActivate: { activeResultSetId = rs.id }, onClose: rs.isPinned ? nil : { onClose?(rs.id) } ) + .help(provenance(of: rs)) .contextMenu { - Button(rs.isPinned ? String(localized: "Unpin") : String(localized: "Pin Result")) { + Button(rs.isPinned ? String(localized: "Unpin Result") : String(localized: "Pin Result")) { onPin?(rs.id) } Divider() @@ -50,6 +52,24 @@ struct ResultTabBar: View { } } } + + private func provenance(of rs: ResultSet) -> String { + if let query = rs.baseQuery?.trimmingCharacters(in: .whitespacesAndNewlines), !query.isEmpty { + return Self.truncated(query) + } + if let errorMessage = rs.errorMessage { + return Self.truncated(errorMessage) + } + return rs.label + } + + private static func truncated(_ text: String) -> String { + let value = text as NSString + guard value.length > tooltipCharacterLimit else { return text } + return value.substring(to: tooltipCharacterLimit) + "…" + } + + private static let tooltipCharacterLimit = 300 } private struct ResultTab: View { @@ -68,6 +88,7 @@ private struct ResultTab: View { Image(systemName: "pin.fill") .font(.caption2) .foregroundStyle(isActive ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary)) + .accessibilityHidden(true) } Text(label) .font(.callout) @@ -90,6 +111,13 @@ private struct ResultTab: View { } .buttonStyle(.plain) .onHover { isHovering = $0 } + .accessibilityLabel(accessibilityLabel) + .accessibilityAddTraits(isActive ? [.isSelected] : []) + } + + private var accessibilityLabel: String { + guard isPinned else { return label } + return String(format: String(localized: "%@, pinned"), label) } private var background: AnyShapeStyle { diff --git a/TableProTests/Views/Main/ResultPinningTests.swift b/TableProTests/Views/Main/ResultPinningTests.swift new file mode 100644 index 000000000..bdf996519 --- /dev/null +++ b/TableProTests/Views/Main/ResultPinningTests.swift @@ -0,0 +1,231 @@ +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@Suite("ResultPinning") +struct ResultPinningTests { + @Test("A new execution replaces unpinned results and keeps pinned ones") + @MainActor + func replaceKeepsPinnedResults() { + var display = TabDisplayState() + let pinned = Self.makeResultSet(label: "kept", isPinned: true) + let scratch = Self.makeResultSet(label: "scratch") + display.resultSets = [pinned, scratch] + display.activeResultSetId = scratch.id + + let fresh = Self.makeResultSet(label: "fresh") + display.replaceUnpinnedResults(with: [fresh]) + + #expect(display.resultSets.map(\.id) == [pinned.id, fresh.id]) + #expect(display.activeResultSetId == fresh.id) + } + + @Test("A new execution never targets a pinned result when every result is pinned") + @MainActor + func replaceWhenAllResultsArePinned() { + var display = TabDisplayState() + let first = Self.makeResultSet(label: "first", isPinned: true) + let second = Self.makeResultSet(label: "second", isPinned: true) + display.resultSets = [first, second] + display.activeResultSetId = second.id + + let fresh = Self.makeResultSet(label: "fresh") + display.replaceUnpinnedResults(with: [fresh]) + + #expect(display.resultSets.map(\.id) == [first.id, second.id, fresh.id]) + #expect(display.activeResultSetId == fresh.id) + #expect(first.isPinned) + #expect(second.isPinned) + } + + @Test("A multi-statement execution appends every statement result after the pinned ones") + @MainActor + func replaceWithMultipleStatementResults() { + var display = TabDisplayState() + let pinned = Self.makeResultSet(label: "kept", isPinned: true) + display.resultSets = [pinned, Self.makeResultSet(label: "scratch")] + + let first = Self.makeResultSet(label: "Result 1") + let second = Self.makeResultSet(label: "Result 2") + display.replaceUnpinnedResults(with: [first, second]) + + #expect(display.resultSets.map(\.id) == [pinned.id, first.id, second.id]) + #expect(display.activeResultSetId == second.id) + } + + @Test("Removing unpinned results keeps the pinned ones and reactivates the last") + @MainActor + func removeUnpinnedKeepsPinned() { + var display = TabDisplayState() + let pinned = Self.makeResultSet(label: "kept", isPinned: true) + let scratch = Self.makeResultSet(label: "scratch") + display.resultSets = [pinned, scratch] + display.activeResultSetId = scratch.id + + display.removeUnpinnedResults() + + #expect(display.resultSets.map(\.id) == [pinned.id]) + #expect(display.activeResultSetId == pinned.id) + } + + @Test("Clearing results keeps a pinned result and its rows") + @MainActor + func clearKeepsPinnedResultAndRows() throws { + let coordinator = Self.makeCoordinator() + defer { coordinator.teardown() } + + coordinator.tabManager.addTab(databaseName: "db") + let tabId = try #require(coordinator.tabManager.selectedTab?.id) + let index = try #require(coordinator.tabManager.selectedTabIndex) + + let pinnedRows = TestFixtures.makeTableRows(rowCount: 3) + let pinned = ResultSet(label: "kept", tableRows: pinnedRows) + pinned.isPinned = true + let scratch = ResultSet(label: "scratch", tableRows: TestFixtures.makeTableRows(rowCount: 7)) + + coordinator.tabManager.mutate(at: index) { tab in + tab.display.resultSets = [pinned, scratch] + tab.display.activeResultSetId = scratch.id + tab.execution.lastExecutedAt = Date() + } + coordinator.setActiveTableRows(scratch.tableRows, for: tabId) + + coordinator.clearActiveQueryResults() + + let tab = try #require(coordinator.tabManager.selectedTab) + #expect(tab.display.resultSets.map(\.id) == [pinned.id]) + #expect(tab.display.activeResultSetId == pinned.id) + #expect(pinned.tableRows.rows.count == 3) + #expect(coordinator.tabSessionRegistry.tableRows(for: tabId).rows.count == 3) + #expect(tab.display.isResultsCollapsed == false) + } + + @Test("Closing a pinned result is refused") + @MainActor + func closeRefusesPinnedResult() throws { + let coordinator = Self.makeCoordinator() + defer { coordinator.teardown() } + + coordinator.tabManager.addTab(databaseName: "db") + let index = try #require(coordinator.tabManager.selectedTabIndex) + let pinned = Self.makeResultSet(label: "kept", isPinned: true) + coordinator.tabManager.mutate(at: index) { tab in + tab.display.resultSets = [pinned] + tab.display.activeResultSetId = pinned.id + } + + coordinator.closeResultSet(id: pinned.id) + + #expect(coordinator.tabManager.selectedTab?.display.resultSets.map(\.id) == [pinned.id]) + } + + @Test("Toggling pin flips the flag on the addressed result") + @MainActor + func togglePinFlipsFlag() throws { + let coordinator = Self.makeCoordinator() + defer { coordinator.teardown() } + + coordinator.tabManager.addTab(databaseName: "db") + let index = try #require(coordinator.tabManager.selectedTabIndex) + let result = Self.makeResultSet(label: "Result") + coordinator.tabManager.mutate(at: index) { tab in + tab.display.resultSets = [result] + tab.display.activeResultSetId = result.id + } + + #expect(coordinator.isActiveResultSetPinned == false) + coordinator.togglePinResultSet(id: result.id) + #expect(result.isPinned) + #expect(coordinator.isActiveResultSetPinned) + + coordinator.togglePinResultSet(id: result.id) + #expect(result.isPinned == false) + } + + @Test("A single result can be pinned; a table tab result cannot") + @MainActor + func pinGating() throws { + let coordinator = Self.makeCoordinator() + defer { coordinator.teardown() } + + coordinator.tabManager.addTab(databaseName: "db") + #expect(coordinator.canPinActiveResultSet == false) + + let index = try #require(coordinator.tabManager.selectedTabIndex) + let result = Self.makeResultSet(label: "Result") + coordinator.tabManager.mutate(at: index) { tab in + tab.display.resultSets = [result] + tab.display.activeResultSetId = result.id + } + #expect(coordinator.canPinActiveResultSet) + + try coordinator.tabManager.addTableTab(tableName: "users", databaseType: .mysql, databaseName: "db") + let tableIndex = try #require(coordinator.tabManager.selectedTabIndex) + let browsed = Self.makeResultSet(label: "users") + coordinator.tabManager.mutate(at: tableIndex) { tab in + tab.display.resultSets = [browsed] + tab.display.activeResultSetId = browsed.id + } + #expect(coordinator.canPinActiveResultSet == false) + } + + @Test("A tab holding a pinned result is not reusable") + @MainActor + func pinnedResultBlocksTabReuse() throws { + let coordinator = Self.makeCoordinator() + defer { coordinator.teardown() } + + coordinator.tabManager.addTab(databaseName: "db") + #expect(coordinator.isActiveTabReusable) + + let index = try #require(coordinator.tabManager.selectedTabIndex) + let pinned = Self.makeResultSet(label: "kept", isPinned: true) + coordinator.tabManager.mutate(at: index) { tab in + tab.display.resultSets = [pinned] + tab.display.activeResultSetId = pinned.id + } + + #expect(coordinator.isActiveTabReusable == false) + } + + @Test("Reusing a tab for another table drops its result sets") + @MainActor + func replacingTabContentDropsResultSets() throws { + let tabManager = QueryTabManager() + tabManager.addTab(databaseName: "db") + let index = try #require(tabManager.selectedTabIndex) + let pinned = Self.makeResultSet(label: "orders", isPinned: true) + tabManager.mutate(at: index) { tab in + tab.display.resultSets = [pinned] + tab.display.activeResultSetId = pinned.id + } + + let replaced = try tabManager.replaceTabContent( + tableName: "users", databaseType: .mysql, databaseName: "db" + ) + + #expect(replaced) + let tab = try #require(tabManager.selectedTab) + #expect(tab.display.resultSets.isEmpty) + #expect(tab.display.activeResultSetId == nil) + } + + @MainActor + private static func makeResultSet(label: String, isPinned: Bool = false) -> ResultSet { + let resultSet = ResultSet(label: label) + resultSet.isPinned = isPinned + return resultSet + } + + @MainActor + private static func makeCoordinator() -> MainContentCoordinator { + MainContentCoordinator( + connection: TestFixtures.makeConnection(database: "db"), + tabManager: QueryTabManager(), + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + } +} diff --git a/docs/features/keyboard-shortcuts.mdx b/docs/features/keyboard-shortcuts.mdx index 225771edb..5b4c2d082 100644 --- a/docs/features/keyboard-shortcuts.mdx +++ b/docs/features/keyboard-shortcuts.mdx @@ -227,6 +227,7 @@ Inherits the standard `NSDocument` shortcuts. |--------|----------| | Previous Result | `Cmd+Option+[` | | Next Result | `Cmd+Option+]` | +| Pin Result | `Cmd+Option+P` | | Close Result Tab | `Cmd+Shift+W` | ### AI diff --git a/docs/features/sql-editor.mdx b/docs/features/sql-editor.mdx index dba43212e..3d9bbeac3 100644 --- a/docs/features/sql-editor.mdx +++ b/docs/features/sql-editor.mdx @@ -146,13 +146,13 @@ The trash button in the editor toolbar clears the query and its results together 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 +#### 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`. +Every query result gets a tab above the grid. Running multiple statements separated by `;` produces one tab per statement. Switch between tabs by clicking or with `Cmd+Opt+[` / `Cmd+Opt+]`. Close a result tab with `Cmd+Shift+W`. Hover a tab to see the query that produced it. #### 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. +Right-click a result tab and select **Pin Result**, or press `Cmd+Opt+P`, to keep it. The next query lands in a new tab instead of overwriting the pinned one, so you can compare an old result against a new one. Pinned tabs cannot be closed or cleared until you unpin them, and a tab holding a pinned result is never reused to browse another table. #### Inline Errors