diff --git a/CHANGELOG.md b/CHANGELOG.md index c395a56ea..a480be547 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **File > Close All Tabs**, **Close Other Tabs**, and **Close Tabs for Other Databases** close a group of tabs in one step instead of one at a time. Closing every tab leaves the window open on its empty state with the connection still live, and each closed tab stays in **Recently Closed**. None of the three has a shortcut out of the box; bind them in **Settings > Keyboard**. (#1972) - AI Explain, Optimize, and Fix Error now answer with a walkthrough in the chat panel: a before/after SQL diff you can switch between unified and split, numbered steps anchored to the lines they change, and a follow-up prompt on any step. Optimize and Fix add an Apply to Editor button that asks before replacing your query. (#1945) - Create a connection from a project folder. Pick one from the welcome screen or File > Open Project Folder..., and TablePro reads the database settings it finds in `.env` files, `wp-config.php`, `prisma/schema.prisma`, `config/database.yml`, `docker-compose.yml`, `application.properties`, `application.yml`, and `appsettings.json`. A project that uses more than one engine gets a row for each. Review what it found, pick one, and the connection form opens filled in. Nothing is saved or connected until you save it. (#1959) +- A pin button on result tabs. It shows on the tab you are on and when you point at a tab, and stays visible once the result is pinned. Pinned results move to the front of the strip. Pinning was already there, but only as a right-click item and a View menu command, so nobody found it. (#1982) ### Changed @@ -23,6 +24,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The macOS local network prompt now says why TablePro wants access. The app never declared a reason, so the prompt showed a generic message that was easy to deny, which left SSH tunnels and databases on your own network failing with "no route to host". - An SSH tunnel no longer dies while it is busy. The keep-alive read a send that had to wait as a dead tunnel, so heavy traffic through the tunnel could tear it down along with every connection using it. - A `ProxyCommand` line in `~/.ssh/config` no longer passes without a word. TablePro cannot run the command, so it connects straight to the hostname and can land somewhere `ssh` never would. It now logs that it skipped the directive. `ProxyJump` is still followed as before. +- Right-clicking below the SQL editor opens the menu for what you clicked. A query taller than the editor pane made the editor answer right-clicks on the result tabs, the data grid, and the status bar with its own menu, which put **Pin Result**, the row actions, and **Clear Results** out of reach. (#1982) +- **View > Pin Result** is enabled only when there is a result tab to pin. It stayed on in JSON view, during Explain, and in Structure view, where it either did nothing or pinned a result you could not see. Result tabs now also show in JSON view, so a multi-statement run lets you switch between its results there. (#1982) - Closing a window that holds more than one tab now asks about unsaved changes in any of them, not just the tab on screen. (#1972) - MongoDB no longer fails with an authentication error when you open a database your credentials do not belong to. TablePro authenticated against whichever database you were browsing instead of the one set on the connection, which left the connection broken until you deleted and recreated it. Creating a database hit this every time. (#1970) - Creating a MongoDB database now asks for its first collection and keeps it. The database was created and then removed again, because MongoDB drops a database as soon as its last collection goes. (#1970) diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Gutter/GutterView.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Gutter/GutterView.swift index ee1676ae1..d04749e61 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Gutter/GutterView.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Gutter/GutterView.swift @@ -187,6 +187,12 @@ public class GutterView: NSView { fatalError("init(coder:) has not been implemented") } + /// The gutter floats on top of the text view, so a right-click on the line numbers resolves to the + /// gutter rather than the editor. Forward the menu so the editor answers for its own ruler. + override public func menu(for event: NSEvent) -> NSMenu? { + textView?.menu(for: event) + } + /// Updates the width of the gutter if needed to match the maximum line number found as well as the folding ribbon. func updateWidthIfNeeded() { guard let textView else { return } diff --git a/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextView/TextView+Menu.swift b/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextView/TextView+Menu.swift index 508f0caf6..fde1fca05 100644 --- a/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextView/TextView+Menu.swift +++ b/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextView/TextView+Menu.swift @@ -8,11 +8,17 @@ import AppKit extension TextView { + /// Returns the menu assigned to this text view, falling back to the standard editing items. + /// Resolution runs through `NSView.menu(for:)`, so AppKit's own hit testing decides which view + /// owns the click rather than a view guessing from event coordinates. override public func menu(for event: NSEvent) -> NSMenu? { guard event.type == .rightMouseDown else { return nil } - let menu = NSMenu() + if let assignedMenu = super.menu(for: event) { + return assignedMenu + } + let menu = NSMenu() menu.items = [ NSMenuItem(title: "Cut", action: #selector(cut(_:)), keyEquivalent: "x"), NSMenuItem(title: "Copy", action: #selector(copy(_:)), keyEquivalent: "c"), diff --git a/TablePro/Models/Query/QueryTabState.swift b/TablePro/Models/Query/QueryTabState.swift index c9cc00303..2349da3a9 100644 --- a/TablePro/Models/Query/QueryTabState.swift +++ b/TablePro/Models/Query/QueryTabState.swift @@ -470,6 +470,13 @@ struct TabDisplayState: Equatable { activeResultSetId = resultSets.last?.id } + @MainActor + mutating func togglePin(resultSetId: UUID) { + guard let target = resultSets.first(where: { $0.id == resultSetId }) else { return } + target.isPinned.toggle() + resultSets = resultSets.filter(\.isPinned) + resultSets.filter { !$0.isPinned } + } + static func == (lhs: TabDisplayState, rhs: TabDisplayState) -> Bool { lhs.resultsViewMode == rhs.resultsViewMode && lhs.isResultsCollapsed == rhs.isResultsCollapsed diff --git a/TablePro/Models/Query/ResultTabBarPolicy.swift b/TablePro/Models/Query/ResultTabBarPolicy.swift new file mode 100644 index 000000000..7eb6673dd --- /dev/null +++ b/TablePro/Models/Query/ResultTabBarPolicy.swift @@ -0,0 +1,23 @@ +// +// ResultTabBarPolicy.swift +// TablePro +// +// Decides when the result tab strip is on screen and when a result can be pinned. +// Both answers come from here so the strip, its context menus, and the View menu never disagree. +// + +import Foundation + +enum ResultTabBarPolicy { + static func showsTabBar(tabType: TabType, display: TabDisplayState) -> Bool { + guard tabType == .query else { return false } + guard display.explainText == nil else { return false } + guard display.resultsViewMode != .structure else { return false } + return !display.resultSets.isEmpty + } + + static func canPin(tabType: TabType, display: TabDisplayState) -> Bool { + guard showsTabBar(tabType: tabType, display: display) else { return false } + return display.activeResultSet != nil + } +} diff --git a/TablePro/Views/Editor/EditorEventRouter.swift b/TablePro/Views/Editor/EditorEventRouter.swift index 766a4830b..455222041 100644 --- a/TablePro/Views/Editor/EditorEventRouter.swift +++ b/TablePro/Views/Editor/EditorEventRouter.swift @@ -2,8 +2,8 @@ // EditorEventRouter.swift // TablePro // -// Shared event router that installs one set of process-global monitors -// and dispatches to the correct editor by window, replacing per-editor monitors. +// Registry of the live SQL editors, used to dispatch window-scoped commands +// to the editor in the key window. // @preconcurrency import AppKit @@ -21,7 +21,6 @@ internal final class EditorEventRouter { } private var editors: [ObjectIdentifier: EditorRef] = [:] - private var rightClickMonitor: Any? private init() {} @@ -31,10 +30,6 @@ internal final class EditorEventRouter { let key = ObjectIdentifier(coordinator) editors[key] = EditorRef(coordinator: coordinator, textView: textView) - if rightClickMonitor == nil { - installMonitors() - } - if textView.window != nil { installWindowObserver(for: key) } else { @@ -52,10 +47,6 @@ internal final class EditorEventRouter { } editors.removeValue(forKey: key) purgeStaleEntries() - - if editors.isEmpty { - removeMonitors() - } } // MARK: - Per-Window Observer @@ -137,35 +128,4 @@ internal final class EditorEventRouter { private func purgeStaleEntries() { editors = editors.filter { $0.value.coordinator != nil && $0.value.textView != nil } } - - // MARK: - Monitor Installation - - private func installMonitors() { - rightClickMonitor = NSEvent.addLocalMonitorForEvents(matching: .rightMouseDown) { [weak self] nsEvent in - guard let self else { return nsEvent } - nonisolated(unsafe) let event = nsEvent - return MainActor.assumeIsolated { - self.handleRightClick(event) - } - } - } - - private func removeMonitors() { - if let monitor = rightClickMonitor { - NSEvent.removeMonitor(monitor) - rightClickMonitor = nil - } - } - - // MARK: - Event Handlers - - private func handleRightClick(_ event: NSEvent) -> NSEvent? { - guard let (coordinator, textView) = editor(for: event.window) else { return event } - - let locationInView = textView.convert(event.locationInWindow, from: nil) - guard textView.bounds.contains(locationInView) else { return event } - - coordinator.showContextMenu(for: event, in: textView) - return nil - } } diff --git a/TablePro/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index ad554a24b..c86110c84 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -212,6 +212,7 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { onAIOptimize = nil onSaveAsFavorite = nil schemaProvider = nil + controller?.textView?.menu = nil contextMenu = nil vimEngine = nil vimCursorManager = nil @@ -264,6 +265,7 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { menu.onSaveAsFavorite = { [weak self] text in self?.onSaveAsFavorite?(text) } menu.onFormatSQL = { [weak self] in self?.performFormatSQL() } contextMenu = menu + controller.textView?.menu = menu } func performFormatSQL() { @@ -299,15 +301,6 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { } } - /// Called by EditorEventRouter when a right-click is detected in this editor's text view. - func showContextMenu(for event: NSEvent, in textView: TextView) { - if contextMenu == nil, let controller { - installAIContextMenu(controller: controller) - } - guard let menu = contextMenu else { return } - NSMenu.popUpContextMenu(menu, with: event, for: textView) - } - // MARK: - Inline Suggestion Manager private func installInlineSuggestionManager(controller: TextViewController) { diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index b84c6a4ba..7982c4da6 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -538,6 +538,7 @@ struct MainEditorContentView: View { .frame(maxHeight: .infinity) } case .json: + resultTabBarSection(tab: tab) ResultsJsonView( tableRows: resolvedTableRows(for: tab), selectedRowIndices: selectionState.indices @@ -547,10 +548,7 @@ struct MainEditorContentView: View { ExplainResultView(text: explainText, executionTime: tab.display.explainExecutionTime, plan: tab.display.explainPlan) .frame(maxWidth: .infinity, maxHeight: .infinity) } else { - if showsResultTabBar(for: tab) { - resultTabBar(tab: tab) - Divider() - } + resultTabBarSection(tab: tab) let resolvedRows = resolvedTableRows(for: tab) if let rs = tab.display.activeResultSet, rs.resultColumns.isEmpty, @@ -611,9 +609,12 @@ 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 + @ViewBuilder + private func resultTabBarSection(tab: QueryTab) -> some View { + if ResultTabBarPolicy.showsTabBar(tabType: tab.tabType, display: tab.display) { + resultTabBar(tab: tab) + Divider() + } } private func resultTabBar(tab: QueryTab) -> some View { @@ -628,7 +629,7 @@ struct MainEditorContentView: View { onClose: { id in coordinator.closeResultSet(id: id) }, - onPin: { id in + onTogglePin: { id in coordinator.togglePinResultSet(id: id) } ) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift index 45050b016..c7f23e1e3 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift @@ -14,8 +14,8 @@ 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 + guard let tab = tabManager.selectedTab else { return false } + return ResultTabBarPolicy.canPin(tabType: tab.tabType, display: tab.display) } var isActiveResultSetPinned: Bool { @@ -23,10 +23,8 @@ extension MainContentCoordinator { } 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() + guard let tabIdx = tabManager.selectedTabIndex else { return } + tabManager.mutate(at: tabIdx) { $0.display.togglePin(resultSetId: id) } } func closeResultSet(id: UUID) { diff --git a/TablePro/Views/Results/ResultTabBar.swift b/TablePro/Views/Results/ResultTabBar.swift index 952287966..5770f7f29 100644 --- a/TablePro/Views/Results/ResultTabBar.swift +++ b/TablePro/Views/Results/ResultTabBar.swift @@ -13,7 +13,7 @@ struct ResultTabBar: View { let resultSets: [ResultSet] @Binding var activeResultSetId: UUID? var onClose: ((UUID) -> Void)? - var onPin: ((UUID) -> Void)? + var onTogglePin: ((UUID) -> Void)? var body: some View { ScrollView(.horizontal, showsIndicators: false) { @@ -29,26 +29,29 @@ struct ResultTabBar: View { } private func resultTab(_ rs: ResultSet) -> some View { - let isActive = rs.id == (activeResultSetId ?? resultSets.last?.id) - return ResultTab( + ResultTab( label: rs.label, isPinned: rs.isPinned, - isActive: isActive, + isActive: rs.id == (activeResultSetId ?? resultSets.last?.id), onActivate: { activeResultSetId = rs.id }, + onTogglePin: { onTogglePin?(rs.id) }, onClose: rs.isPinned ? nil : { onClose?(rs.id) } ) .help(provenance(of: rs)) - .contextMenu { - Button(rs.isPinned ? String(localized: "Unpin Result") : 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) - } + .contextMenu { menuItems(for: rs) } + } + + @ViewBuilder + private func menuItems(for rs: ResultSet) -> some View { + Button(rs.isPinned ? String(localized: "Unpin Result") : String(localized: "Pin Result")) { + onTogglePin?(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) } } } @@ -77,42 +80,98 @@ private struct ResultTab: View { let isPinned: Bool let isActive: Bool let onActivate: () -> Void + let onTogglePin: () -> Void let onClose: (() -> Void)? @State private var isHovering = false + @ViewBuilder var body: some View { + if let onClose { + annotatedPill.accessibilityAction(named: Text(closeTitle), onClose) + } else { + annotatedPill + } + } + + private var annotatedPill: some View { + pill + .accessibilityLabel(accessibilityLabel) + .accessibilityAddTraits(isActive ? [.isSelected] : []) + .accessibilityAction(named: Text(pinActionTitle), onTogglePin) + } + + private var pill: some View { Button(action: onActivate) { HStack(spacing: 4) { - if isPinned { - Image(systemName: "pin.fill") - .font(.caption2) - .foregroundStyle(isActive ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary)) - .accessibilityHidden(true) - } + closeControl Text(label) .font(.callout) .lineLimit(1) .foregroundStyle(isActive ? AnyShapeStyle(.primary) : AnyShapeStyle(.secondary)) - if let onClose { - Button(action: onClose) { - Image(systemName: "xmark") - .font(.caption2) - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - .accessibilityLabel(String(localized: "Close result tab")) - } + pinControl } - .padding(.horizontal, 10) + .padding(.horizontal, 8) .padding(.vertical, 5) .background(background, in: RoundedRectangle(cornerRadius: 6)) - .contentShape(RoundedRectangle(cornerRadius: 6)) + .frame(maxHeight: .infinity) + .contentShape(Rectangle()) } .buttonStyle(.plain) .onHover { isHovering = $0 } - .accessibilityLabel(accessibilityLabel) - .accessibilityAddTraits(isActive ? [.isSelected] : []) + .accessibilityIdentifier("result-tab") + } + + @ViewBuilder + private var closeControl: some View { + if let onClose { + Button(action: onClose) { + Image(systemName: "xmark") + .font(.caption2) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .frame(width: Self.controlSize, height: Self.controlSize) + .opacity(isRevealed ? 1 : 0) + .help(hint(closeTitle, for: .closeResultTab)) + .accessibilityLabel(closeTitle) + .accessibilityIdentifier("result-tab-close") + } else { + Color.clear + .frame(width: Self.controlSize, height: Self.controlSize) + .accessibilityHidden(true) + } + } + + private var pinControl: some View { + Button(action: onTogglePin) { + Image(systemName: isPinned ? "pin.fill" : "pin") + .font(.caption2) + .foregroundStyle(isPinned ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary)) + } + .buttonStyle(.plain) + .frame(width: Self.controlSize, height: Self.controlSize) + .opacity(isPinned || isRevealed ? 1 : 0) + .help(hint(pinActionTitle, for: .pinResultTab)) + .accessibilityLabel(pinActionTitle) + .accessibilityIdentifier("result-tab-pin") + } + + private func hint(_ title: String, for action: ShortcutAction) -> String { + guard isActive else { return title } + return AppSettingsManager.shared.keyboard.shortcutHint(title, for: action) + } + + private var isRevealed: Bool { + isActive || isHovering + } + + private var pinActionTitle: String { + isPinned ? String(localized: "Unpin Result") : String(localized: "Pin Result") + } + + private var closeTitle: String { + String(localized: "Close result tab") } private var accessibilityLabel: String { @@ -129,4 +188,6 @@ private struct ResultTab: View { AnyShapeStyle(.clear) } } + + private static let controlSize: CGFloat = 12 } diff --git a/TableProTests/Models/Query/ResultTabBarPolicyTests.swift b/TableProTests/Models/Query/ResultTabBarPolicyTests.swift new file mode 100644 index 000000000..e60aa2c6e --- /dev/null +++ b/TableProTests/Models/Query/ResultTabBarPolicyTests.swift @@ -0,0 +1,108 @@ +// +// ResultTabBarPolicyTests.swift +// TableProTests +// +// Guards the invariant behind #1982: a result that the View menu says can be pinned +// always has a result tab on screen to pin it from. +// + +import Foundation +@testable import TablePro +import Testing + +@MainActor +@Suite("ResultTabBarPolicy") +struct ResultTabBarPolicyTests { + @Test("A query tab with a result shows the strip and can pin") + func queryTabWithResult() { + let display = Self.makeDisplay() + + #expect(ResultTabBarPolicy.showsTabBar(tabType: .query, display: display)) + #expect(ResultTabBarPolicy.canPin(tabType: .query, display: display)) + } + + @Test("JSON view keeps the strip so its results stay switchable and pinnable") + func jsonViewKeepsStrip() { + var display = Self.makeDisplay() + display.resultsViewMode = .json + + #expect(ResultTabBarPolicy.showsTabBar(tabType: .query, display: display)) + #expect(ResultTabBarPolicy.canPin(tabType: .query, display: display)) + } + + @Test("Structure view has no result strip and nothing to pin") + func structureViewHasNoStrip() { + var display = Self.makeDisplay() + display.resultsViewMode = .structure + + #expect(ResultTabBarPolicy.showsTabBar(tabType: .query, display: display) == false) + #expect(ResultTabBarPolicy.canPin(tabType: .query, display: display) == false) + } + + @Test("An explain result is not a result set, so nothing can be pinned") + func explainHasNothingToPin() { + var display = Self.makeDisplay() + display.explainText = "Seq Scan on orders" + + #expect(ResultTabBarPolicy.showsTabBar(tabType: .query, display: display) == false) + #expect(ResultTabBarPolicy.canPin(tabType: .query, display: display) == false) + } + + @Test("A tab with no results has no strip and nothing to pin") + func emptyResultsHaveNothingToPin() { + let display = TabDisplayState() + + #expect(ResultTabBarPolicy.showsTabBar(tabType: .query, display: display) == false) + #expect(ResultTabBarPolicy.canPin(tabType: .query, display: display) == false) + } + + @Test("Only query tabs pin results") + func onlyQueryTabsPin() { + let display = Self.makeDisplay() + let others: [TabType] = [.table, .createTable, .erDiagram, .serverDashboard, .usersRoles] + + for tabType in others { + #expect(ResultTabBarPolicy.showsTabBar(tabType: tabType, display: display) == false) + #expect(ResultTabBarPolicy.canPin(tabType: tabType, display: display) == false) + } + } + + @Test("Collapsing the results panel does not take pinning away") + func collapsedResultsStayPinnable() { + var display = Self.makeDisplay() + display.isResultsCollapsed = true + + #expect(ResultTabBarPolicy.canPin(tabType: .query, display: display)) + } + + @Test("A result is never pinnable without a strip to pin it from") + func pinningNeverOutrunsTheStrip() { + var states: [TabDisplayState] = [TabDisplayState(), Self.makeDisplay()] + for mode in [ResultsViewMode.data, .structure, .json] { + var display = Self.makeDisplay() + display.resultsViewMode = mode + states.append(display) + + var explaining = display + explaining.explainText = "plan" + states.append(explaining) + } + + let tabTypes: [TabType] = [.query, .table, .createTable, .erDiagram, .serverDashboard, .usersRoles] + for display in states { + for tabType in tabTypes { + let canPin = ResultTabBarPolicy.canPin(tabType: tabType, display: display) + let showsTabBar = ResultTabBarPolicy.showsTabBar(tabType: tabType, display: display) + #expect(!canPin || showsTabBar) + } + } + } + + private static func makeDisplay() -> TabDisplayState { + var display = TabDisplayState() + let result = ResultSet(label: "Result") + display.resultSets = [result] + display.activeResultSetId = result.id + return display + } +} diff --git a/TableProTests/Views/Editor/EditorContextMenuRoutingTests.swift b/TableProTests/Views/Editor/EditorContextMenuRoutingTests.swift new file mode 100644 index 000000000..905d2a6a6 --- /dev/null +++ b/TableProTests/Views/Editor/EditorContextMenuRoutingTests.swift @@ -0,0 +1,53 @@ +// +// EditorContextMenuRoutingTests.swift +// TableProTests +// +// Covers the menu resolution the #1982 fix depends on: an assigned menu wins, and a +// text view without one keeps the standard editing items. The routing itself, that a +// right-click below the editor reaches the result tabs rather than the editor, is +// covered end to end by ResultTabPinUITests. +// + +import AppKit +import CodeEditTextView +import Testing + +@MainActor +@Suite("Editor context menu routing") +struct EditorContextMenuRoutingTests { + @Test("A menu assigned to the text view wins over the standard editing items") + func assignedMenuIsResolved() { + let textView = TextView(string: "SELECT 1") + let menu = NSMenu(title: "") + textView.menu = menu + + #expect(textView.menu(for: Self.rightClick()) === menu) + } + + @Test("A text view with no assigned menu keeps the standard editing items") + func defaultEditingMenuSurvives() { + let textView = TextView(string: "SELECT 1") + + let resolved = textView.menu(for: Self.rightClick()) + + #expect(textView.menu == nil) + #expect(resolved?.items.map(\.title) == ["Cut", "Copy", "Paste"]) + } + + private static func rightClick() -> NSEvent { + guard let event = NSEvent.mouseEvent( + with: .rightMouseDown, + location: NSPoint(x: 4, y: 4), + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + eventNumber: 0, + clickCount: 1, + pressure: 1 + ) else { + fatalError("Failed to build a right-click event") + } + return event + } +} diff --git a/TableProTests/Views/Main/ResultPinningTests.swift b/TableProTests/Views/Main/ResultPinningTests.swift index bdf996519..bfdd4144c 100644 --- a/TableProTests/Views/Main/ResultPinningTests.swift +++ b/TableProTests/Views/Main/ResultPinningTests.swift @@ -144,6 +144,104 @@ struct ResultPinningTests { #expect(result.isPinned == false) } + @Test("Pinning moves the result in front of the unpinned ones") + @MainActor + func pinningMovesResultToTheFront() { + var display = TabDisplayState() + let first = Self.makeResultSet(label: "first") + let second = Self.makeResultSet(label: "second") + let third = Self.makeResultSet(label: "third") + display.resultSets = [first, second, third] + display.activeResultSetId = second.id + + display.togglePin(resultSetId: third.id) + + #expect(display.resultSets.map(\.id) == [third.id, first.id, second.id]) + #expect(third.isPinned) + #expect(display.activeResultSetId == second.id) + } + + @Test("Unpinning moves the result back behind the pinned ones") + @MainActor + func unpinningMovesResultBehindPinned() { + var display = TabDisplayState() + let kept = Self.makeResultSet(label: "kept", isPinned: true) + let released = Self.makeResultSet(label: "released", isPinned: true) + let scratch = Self.makeResultSet(label: "scratch") + display.resultSets = [kept, released, scratch] + + display.togglePin(resultSetId: released.id) + + #expect(display.resultSets.map(\.id) == [kept.id, released.id, scratch.id]) + #expect(released.isPinned == false) + } + + @Test("Toggling pin on an unknown result changes nothing") + @MainActor + func togglePinIgnoresUnknownResult() { + var display = TabDisplayState() + let result = Self.makeResultSet(label: "Result") + display.resultSets = [result] + display.activeResultSetId = result.id + + display.togglePin(resultSetId: UUID()) + + #expect(display.resultSets.map(\.id) == [result.id]) + #expect(result.isPinned == false) + } + + @Test("Pinning does not switch which result is active") + @MainActor + func pinningKeepsActiveResult() 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 first = ResultSet(label: "first", tableRows: TestFixtures.makeTableRows(rowCount: 3)) + let second = ResultSet(label: "second", tableRows: TestFixtures.makeTableRows(rowCount: 7)) + coordinator.tabManager.mutate(at: index) { tab in + tab.display.resultSets = [first, second] + tab.display.activeResultSetId = second.id + } + coordinator.setActiveTableRows(second.tableRows, for: tabId) + + coordinator.togglePinResultSet(id: first.id) + + let tab = try #require(coordinator.tabManager.selectedTab) + #expect(tab.display.activeResultSetId == second.id) + #expect(coordinator.tabSessionRegistry.tableRows(for: tabId).rows.count == 7) + } + + @Test("The View menu and the result strip agree on when a result can be pinned") + @MainActor + func pinGatingMatchesTheStrip() 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") + + for mode in [ResultsViewMode.data, .json, .structure] { + for explainText in [nil, "plan"] as [String?] { + coordinator.tabManager.mutate(at: index) { tab in + tab.display.resultSets = [result] + tab.display.activeResultSetId = result.id + tab.display.resultsViewMode = mode + tab.display.explainText = explainText + } + let tab = try #require(coordinator.tabManager.selectedTab) + #expect( + coordinator.canPinActiveResultSet + == ResultTabBarPolicy.canPin(tabType: tab.tabType, display: tab.display) + ) + } + } + } + @Test("A single result can be pinned; a table tab result cannot") @MainActor func pinGating() throws { diff --git a/TableProUITests/ResultTabPinUITests.swift b/TableProUITests/ResultTabPinUITests.swift new file mode 100644 index 000000000..2b392727d --- /dev/null +++ b/TableProUITests/ResultTabPinUITests.swift @@ -0,0 +1,80 @@ +// +// ResultTabPinUITests.swift +// TableProUITests +// +// Covers #1982: pinning a query result must be reachable from the result tab itself. +// The query is padded past the height of the editor pane because that is what used to +// make the editor claim right-clicks over the results. +// + +import XCTest + +final class ResultTabPinUITests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + override func tearDownWithError() throws { + XCUIApplication().terminate() + } + + func testResultTabExposesPinButtonAndPinMenuItem() throws { + let app = launchWithSampleDatabase() + + let editor = editorTextView(in: app) + XCTAssertTrue(editor.waitForExistence(timeout: 15)) + + app.typeKey("t", modifierFlags: .command) + let queryEditor = editorTextView(in: app) + XCTAssertTrue(queryEditor.waitForExistence(timeout: 10)) + queryEditor.click() + app.typeText(paddedQuery) + app.typeKey(.return, modifierFlags: .command) + + let resultTab = app.buttons["result-tab"].firstMatch + XCTAssertTrue(resultTab.waitForExistence(timeout: 20), "The query must produce a result tab") + resultTab.rightClick() + + let contextMenu = app.menus.firstMatch + XCTAssertTrue( + contextMenu.menuItems["Close Others"].waitForExistence(timeout: 5), + "Right-clicking a result tab must open the result menu, not the editor menu" + ) + contextMenu.menuItems["Pin Result"].click() + + let menuBar = app.menuBars.firstMatch + menuBar.menuBarItems["View"].click() + let unpinItem = menuBar.menuItems["Unpin Result"] + XCTAssertTrue(unpinItem.waitForExistence(timeout: 5), "A pinned result reads as Unpin Result") + XCTAssertTrue(unpinItem.isEnabled) + app.typeKey(.escape, modifierFlags: []) + } + + private var paddedQuery: String { + let padding = (1...60).map { "-- line \($0)" }.joined(separator: "\n") + return "\(padding)\nSELECT 1;" + } + + private func launchWithSampleDatabase() -> XCUIApplication { + let app = XCUIApplication() + app.launchEnvironment["TABLEPRO_UI_TESTING"] = "1" + app.launch() + + let menuBar = app.menuBars.firstMatch + XCTAssertTrue(menuBar.waitForExistence(timeout: 10)) + menuBar.menuBarItems["File"].click() + let openSample = menuBar.menuItems["Open Sample Database"] + XCTAssertTrue(openSample.waitForExistence(timeout: 5)) + openSample.click() + return app + } + + private func editorTextView(in app: XCUIApplication) -> XCUIElement { + let window = app.windows.firstMatch + let identified = window.textViews.matching(identifier: "sql-editor-textview").firstMatch + if identified.exists { + return identified + } + return window.textViews.firstMatch + } +} diff --git a/docs/features/sql-editor.mdx b/docs/features/sql-editor.mdx index bbc6d5632..5aa3dc986 100644 --- a/docs/features/sql-editor.mdx +++ b/docs/features/sql-editor.mdx @@ -78,8 +78,8 @@ When the cap trims a result, the status bar shows **truncated** with a **Fetch A Results appear in the data grid below the editor with row count and execution time. Large result sets are paginated. -- **Result tabs**: every result gets a tab above the grid; multi-statement runs produce one tab per statement. Switch with `Cmd+Option+[` / `Cmd+Option+]`, close with `Cmd+Shift+W`. Hover a tab to see its query. -- **Pinning**: right-click a tab and choose **Pin Result**, or press `Cmd+Option+P`. The next query lands in a new tab instead of overwriting the pinned one. Pinned tabs cannot be closed or cleared until unpinned. +- **Result tabs**: every result gets a tab above the grid; multi-statement runs produce one tab per statement. Tabs show in JSON view too. Switch with `Cmd+Option+[` / `Cmd+Option+]`, close with `Cmd+Shift+W`. Hover a tab to see its query. +- **Pinning**: click the pin on the right of a result tab, press `Cmd+Option+P`, or right-click the tab and choose **Pin Result**. The next query lands in a new tab instead of overwriting the pinned one. Pinned results move to the front of the strip and cannot be closed or cleared until unpinned. - **Panel**: toggle the results panel with `Cmd+Option+R` or the toolbar button; it auto-expands when a query executes. The toolbar trash button clears the query and results together; to keep the query, right-click the results and choose **Clear Results**. - **Errors**: a red banner above the results shows the database error message with a **Fix with AI** button. - **Non-SELECT statements** (INSERT, UPDATE, DELETE, DDL) show a success view with affected row count and execution time.