Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions TablePro/Models/Query/QueryTabManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
14 changes: 14 additions & 0 deletions TablePro/Models/Query/QueryTabState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions TablePro/Models/UI/KeyboardShortcutModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable {
case toggleResults
case previousResultTab
case nextResultTab
case pinResultTab
case closeResultTab
case focusSidebarSearch
case showSidebarTables
Expand All @@ -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
}
}
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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),
Expand Down
9 changes: 9 additions & 0 deletions TablePro/TableProApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
10 changes: 7 additions & 3 deletions TablePro/Views/Main/Child/MainEditorContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down Expand Up @@ -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,
Expand All @@ -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)
}
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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
Comment on lines +65 to +68

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear stale execution state when preserving pins

When a query tab has any pinned result, this branch switches to the pinned result and returns before clearing tab.execution.errorMessage/lastExecutedAt/status fields, unlike the unpinned-only path below. If a user pins a result, runs a single-statement query that fails (handleQueryExecutionError stores the error on tab.execution without adding a ResultSet), and then chooses Clear Results, the error banner remains because executionErrorBanner falls back to tab.execution.errorMessage after the active pinned result has no error. Please clear the same execution fields in the pinned branch as well.

Useful? React with 👍 / 👎.

}

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
Expand Down
14 changes: 14 additions & 0 deletions TablePro/Views/Main/MainContentCommandActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 31 additions & 3 deletions TablePro/Views/Results/ResultTabBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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 {
Expand All @@ -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)
Expand All @@ -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 {
Expand Down
Loading
Loading