From 705bb5f44ddb734560fa6858ccc9762644a224a5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 15 Sep 2026 15:18:24 +0700 Subject: [PATCH] refactor(editor): rebuild the query tab around pure presentation models --- CHANGELOG.md | 11 + TablePro/Core/Menu/QueryMenuBuilder.swift | 13 + ...SplitViewController+QueryMenuActions.swift | 8 + .../MainSplitViewController.swift | 37 + .../Infrastructure/MainWindowToolbar.swift | 2 + .../Infrastructure/TrailingPaneProxy.swift | 6 + .../Infrastructure/WorkspacePanes.swift | 8 +- .../Connection/ConnectionToolbarState.swift | 4 + .../Query/QueryCommandAvailability.swift | 92 +++ .../Query/QueryResultPresentation.swift | 188 +++++ .../Models/Query/ResultSetMenuModel.swift | 67 ++ TablePro/Models/Query/ResultSetPolicy.swift | 22 + TablePro/Models/Query/ResultStatusModel.swift | 5 + .../Query/ResultStatusPresentation.swift | 7 + .../Models/Query/ResultTabBarPolicy.swift | 22 - TablePro/Models/Query/StatusBarSnapshot.swift | 7 + TablePro/Models/UI/TrailingPaneSurface.swift | 7 + TablePro/Resources/Localizable.xcstrings | 716 +++++++++++++++++- .../TrailingPaneUnavailableView.swift | 2 + .../Editor/History/HistoryPanelView.swift | 19 +- TablePro/Views/Editor/QueryEditorBar.swift | 156 ++++ TablePro/Views/Editor/QueryEditorView.swift | 204 +---- .../Views/Editor/VimModeIndicatorView.swift | 14 + .../Main/Child/MainEditorContentView.swift | 453 ++++++----- ...ainContentCoordinator+SidebarActions.swift | 11 +- .../MainContentCoordinator+TabSwitch.swift | 30 + .../Main/MainContentCommandActions.swift | 35 +- TablePro/Views/Results/ResultSetMenu.swift | 78 ++ TablePro/Views/Results/ResultStatusBar.swift | 16 + TablePro/Views/Results/ResultTabBar.swift | 202 ----- .../Query/QueryCommandAvailabilityTests.swift | 108 +++ .../Query/QueryResultPresentationTests.swift | 250 ++++++ .../Query/ResultSetMenuModelTests.swift | 96 +++ .../Models/Query/ResultSetPolicyTests.swift | 135 ++++ .../Query/ResultTabBarPolicyTests.swift | 123 --- .../MainWindowToolbarValidationTests.swift | 1 + .../Views/Main/ResultPinningTests.swift | 2 +- .../Main/ResultStatusBarLayoutTests.swift | 5 + TableProUITests/QueryExecuteMenuUITests.swift | 72 -- TableProUITests/QueryPlanResultUITests.swift | 30 +- TableProUITests/QueryRunUITests.swift | 83 ++ TableProUITests/ResultSetPinUITests.swift | 55 ++ .../ResultStatementLinkUITests.swift | 42 +- TableProUITests/ResultTabPinUITests.swift | 52 -- docs/features/explain-visualization.mdx | 4 +- docs/features/keyboard-shortcuts.mdx | 8 +- docs/features/query-history.mdx | 22 +- docs/features/query-insights.mdx | 2 +- docs/features/query-results.mdx | 30 +- docs/features/sql-editor.mdx | 4 +- docs/features/tabs.mdx | 2 +- 51 files changed, 2637 insertions(+), 931 deletions(-) create mode 100644 TablePro/Models/Query/QueryCommandAvailability.swift create mode 100644 TablePro/Models/Query/QueryResultPresentation.swift create mode 100644 TablePro/Models/Query/ResultSetMenuModel.swift create mode 100644 TablePro/Models/Query/ResultSetPolicy.swift delete mode 100644 TablePro/Models/Query/ResultTabBarPolicy.swift create mode 100644 TablePro/Views/Editor/QueryEditorBar.swift create mode 100644 TablePro/Views/Results/ResultSetMenu.swift delete mode 100644 TablePro/Views/Results/ResultTabBar.swift create mode 100644 TableProTests/Models/Query/QueryCommandAvailabilityTests.swift create mode 100644 TableProTests/Models/Query/QueryResultPresentationTests.swift create mode 100644 TableProTests/Models/Query/ResultSetMenuModelTests.swift create mode 100644 TableProTests/Models/Query/ResultSetPolicyTests.swift delete mode 100644 TableProTests/Models/Query/ResultTabBarPolicyTests.swift delete mode 100644 TableProUITests/QueryExecuteMenuUITests.swift create mode 100644 TableProUITests/QueryRunUITests.swift create mode 100644 TableProUITests/ResultSetPinUITests.swift delete mode 100644 TableProUITests/ResultTabPinUITests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 3aa88ebee5..9dbc18fb74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Row grid for data Compare & Sync with every column shown and each differing value marked. (#2537) - Acknowledgements entries for the four tree-sitter grammars the SQL editor ships. - **Edit > Find > Find and Replace…** (`Cmd+Option+F`) and **Use Selection for Find** (`Cmd+E`) in the SQL editor. +- **Run** split button in the query editor, with Run All Statements, Run Without Limit, Clear Query and Clear Results on its menu. +- **Stop** in the query editor while a query is running. +- **Query > Clear Query** and **Query > Clear Results**. +- Result chooser in the status bar, naming the result on screen and offering Pin, Unpin, Close and Close Others. +- A reason on a dimmed Run, Explain, Format or Favorite saying why it cannot run. ### Changed @@ -50,14 +55,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Connection rows without colored dots, on the Mac and on iOS. - SQL Server sessions open with the ANSI SET profile the server requires, matching every other client. - Compared columns in data Compare & Sync chosen per table, and saved with each table's key, filter and row limit. (#2537) +- Query history opens in the trailing pane beside the inspector and the assistant, with its entry list above its detail. +- Query editor command bar with one control size, the container picker leading and the commands trailing. ### Removed - CodeEditSymbols, a dependency the editor linked and never called, from the app and from Acknowledgements. - `Ctrl+Cmd+J` from the editor's reserved shortcuts, so it can be bound in Settings > Keyboard. +- Result tab strip above the query results, and the "Query" heading above the editor. +- Trash button that cleared the query and the results under one name. +- Query history drawer under the editor and results. ### Fixed +- Stale error banner over a pinned result after clearing the results of a failed query. - Imported connections pointing at an SSH profile that is not on the importing Mac. - Syntax highlighting falling a second or two behind while typing quickly in the SQL editor. - Beep and a question-mark badge when pressing `Ctrl+Cmd+J` in the SQL editor. diff --git a/TablePro/Core/Menu/QueryMenuBuilder.swift b/TablePro/Core/Menu/QueryMenuBuilder.swift index f656ce055a..2613d8cbac 100644 --- a/TablePro/Core/Menu/QueryMenuBuilder.swift +++ b/TablePro/Core/Menu/QueryMenuBuilder.swift @@ -34,6 +34,19 @@ enum QueryMenuBuilder { keyboard: keyboard ), MenuItemFactory.separator, + /// Two commands because they are two effects. The editor's trash button did both under + /// the single name "Clear Query", so neither was announced for what it was and neither + /// had a menu-bar route, which is what the HIG requires before a toolbar item may carry + /// it. They take no default shortcut: both are destructive and infrequent. + MenuItemFactory.item( + String(localized: "Clear Query"), + action: #selector(MainSplitViewController.clearQuery(_:)) + ), + MenuItemFactory.item( + String(localized: "Clear Results"), + action: #selector(MainSplitViewController.clearResults(_:)) + ), + MenuItemFactory.separator, MenuItemFactory.item( String(localized: "Explain Query"), action: #selector(MainSplitViewController.explainQuery(_:)), diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+QueryMenuActions.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+QueryMenuActions.swift index 91fd95592b..b5d1c3bb2b 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+QueryMenuActions.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+QueryMenuActions.swift @@ -66,6 +66,14 @@ extension MainSplitViewController { commandActions?.saveAsFavorite() } + @objc func clearQuery(_ sender: Any?) { + commandActions?.clearQuery() + } + + @objc func clearResults(_ sender: Any?) { + commandActions?.clearResults() + } + @objc func previewFKReference(_ sender: Any?) { commandActions?.previewFKReference() } diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index f1b3c867f8..73a5d54a45 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -717,6 +717,7 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan workspace.panes.detail.rootView = AnyView(buildDetailView(for: workspace)) workspace.panes.inspector.rootView = AnyView(buildInspectorView(for: workspace)) workspace.panes.assistant.rootView = AnyView(buildAssistantView(for: workspace)) + workspace.panes.history.rootView = AnyView(buildHistoryView(for: workspace)) refreshTabStripPane(of: workspace) workspace.panes.markRendered(workspace.paneRenderKey) guard isShowing(workspace) else { return } @@ -897,6 +898,16 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan } } + @ViewBuilder + private func buildHistoryView(for workspace: ConnectionWorkspace) -> some View { + if workspace.resolvedPane == .content, + let coordinator = workspace.sessionState?.coordinator { + HistoryPanelView(coordinator: coordinator) + } else { + TrailingPaneUnavailableView(surface: .history) + } + } + /// Rebuilds the trailing surfaces alone. `commandActions` is read eagerly by both, and it only /// exists once the detail pane has appeared, which is after `rebuildPanes()` has already built /// them against a nil value. Rebuilding the detail pane too would remount the very view that @@ -905,6 +916,7 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan guard let selected = workspaces.selected else { return } selected.panes.inspector.rootView = AnyView(buildInspectorView(for: selected)) selected.panes.assistant.rootView = AnyView(buildAssistantView(for: selected)) + selected.panes.history.rootView = AnyView(buildHistoryView(for: selected)) } /// Parents whichever surface the selected workspace is showing. @@ -1006,6 +1018,10 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan isTrailingPaneOpen && resolvedTrailingSurface == .assistant } + var isHistoryVisible: Bool { + isTrailingPaneOpen && resolvedTrailingSurface == .history + } + func showInspector() { reveal(.inspector) } @@ -1015,6 +1031,10 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan reveal(.assistant) } + func showHistory() { + reveal(.history) + } + /// Auto-show follows a grid click, which is not a request for a different surface. Revealing /// the inspector unconditionally swapped the assistant out from under a half-typed question and /// persisted the inspector as that connection's surface, on every row the user clicked. @@ -1025,9 +1045,25 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan func hideTrailingPane() { inspectorSplitItem?.animator().isCollapsed = true + syncHistoryPanelVisibility() recomputeWindowMinSize() } + /// Keeps `HistoryPanelState.isVisible` saying what the window is actually showing. + /// + /// The flag is not redundant with the surface. It is what `HistoryPanelView`'s `.task(id:)` + /// keys its activation on, so the view model only builds and starts querying while history is + /// on screen, and it is what the Find Past Queries tip reads to know it has been answered. + /// Leaving it behind when history moved into the trailing pane would have left the panel + /// mounted and inert, which is the shape of a pane that renders nothing forever. + private func syncHistoryPanelVisibility() { + guard let connectionId = workspaces.selected?.connectionId else { return } + let showing = isTrailingPaneOpen && resolvedTrailingSurface == .history + let state = HistoryPanelState.forConnection(connectionId) + guard state.isVisible != showing else { return } + state.isVisible = showing + } + /// Puts the hosted child back in step with what the settings now allow. /// /// The stored surface is left alone: a user who turns the assistant off and on again gets it @@ -1044,6 +1080,7 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan rebuildTrailingPanes() showSelectedTrailingPane() inspectorSplitItem?.animator().isCollapsed = false + syncHistoryPanelVisibility() recomputeWindowMinSize() } diff --git a/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift b/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift index 31309ce038..c694c7b3af 100644 --- a/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift +++ b/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift @@ -233,6 +233,7 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { _ = self?.coordinator?.toolbarState.hasDataPendingChanges _ = self?.coordinator?.toolbarState.safeModeLevel _ = self?.coordinator?.toolbarState.currentDatabase + _ = self?.coordinator?.toolbarState.isQueryTab } onChange: { [weak self] in Task { @MainActor [weak self] in guard let self, @@ -365,6 +366,7 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { /// `addRow`, `restorePreviousValues`, `quickSwitcher` and `newTab` are absent on purpose: they /// ride a group as subitems and the delegate vends no standalone item for any of them, so /// listing one here would offer the customization palette a tile it cannot build. + /// internal static let allowedItemIdentifiers: [NSToolbarItem.Identifier] = defaultItemIdentifiers + [ previewSQL, results, diff --git a/TablePro/Core/Services/Infrastructure/TrailingPaneProxy.swift b/TablePro/Core/Services/Infrastructure/TrailingPaneProxy.swift index 890d1e67ed..50fd9c4883 100644 --- a/TablePro/Core/Services/Infrastructure/TrailingPaneProxy.swift +++ b/TablePro/Core/Services/Infrastructure/TrailingPaneProxy.swift @@ -17,8 +17,10 @@ import Foundation internal protocol TrailingPaneProxy: AnyObject { var isInspectorVisible: Bool { get } var isAssistantVisible: Bool { get } + var isHistoryVisible: Bool { get } func showInspector() func showAssistant() + func showHistory() func hideTrailingPane() /// Reveals the inspector for a selection the user made somewhere else, and only if that does @@ -37,4 +39,8 @@ internal extension TrailingPaneProxy { func toggleAssistant() { if isAssistantVisible { hideTrailingPane() } else { showAssistant() } } + + func toggleHistory() { + if isHistoryVisible { hideTrailingPane() } else { showHistory() } + } } diff --git a/TablePro/Core/Services/Infrastructure/WorkspacePanes.swift b/TablePro/Core/Services/Infrastructure/WorkspacePanes.swift index f7aadc13ff..4b45f9802b 100644 --- a/TablePro/Core/Services/Infrastructure/WorkspacePanes.swift +++ b/TablePro/Core/Services/Infrastructure/WorkspacePanes.swift @@ -53,6 +53,10 @@ internal final class WorkspacePanes { /// only way it gets the `sizingOptions` firewall below, which is applied here and nowhere else. internal let assistant: NSHostingController + /// Query history, on the same terms as the assistant: its own controller so the list's scroll + /// position, the selected entry and a half-typed search survive the reader looking at a row. + internal let history: NSHostingController + internal let sidebar: NSHostingController /// The editor tab strip. It is a pane like the other three, built and kept alive per /// connection, even though the window shows it in the titlebar accessory rather than in a @@ -72,6 +76,7 @@ internal final class WorkspacePanes { detail = NSHostingController(rootView: AnyView(Color.clear)) inspector = NSHostingController(rootView: AnyView(Color.clear)) assistant = NSHostingController(rootView: AnyView(Color.clear)) + history = NSHostingController(rootView: AnyView(Color.clear)) sidebar = NSHostingController(rootView: AnyView(Color.clear)) tabStrip = EditorTabStripPaneController() for pane in panes { @@ -80,7 +85,7 @@ internal final class WorkspacePanes { } private var panes: [NSHostingController] { - [detail, inspector, assistant, sidebar] + [detail, inspector, assistant, history, sidebar] } /// The controller a trailing surface is drawn by. One split item hosts whichever of these the @@ -89,6 +94,7 @@ internal final class WorkspacePanes { switch surface { case .inspector: inspector case .assistant: assistant + case .history: history } } diff --git a/TablePro/Models/Connection/ConnectionToolbarState.swift b/TablePro/Models/Connection/ConnectionToolbarState.swift index 3d03aa8b91..9c5fe226b4 100644 --- a/TablePro/Models/Connection/ConnectionToolbarState.swift +++ b/TablePro/Models/Connection/ConnectionToolbarState.swift @@ -129,6 +129,10 @@ final class ConnectionToolbarState { /// Whether the current editor has non-empty query text var hasQueryText: Bool = false + /// Whether the selected tab is a query tab. `isTableTab` cannot answer this: a structure, + /// dashboard or diagram tab is neither, and the Run item has to be disabled on all of them. + var isQueryTab: Bool = false + /// SQL statements rendered in the SQL preview sheet var previewStatements: [String] = [] diff --git a/TablePro/Models/Query/QueryCommandAvailability.swift b/TablePro/Models/Query/QueryCommandAvailability.swift new file mode 100644 index 0000000000..383d51377d --- /dev/null +++ b/TablePro/Models/Query/QueryCommandAvailability.swift @@ -0,0 +1,92 @@ +// +// QueryCommandAvailability.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +/// What the query tab's command bar can do right now. +/// +/// Pure, the way `ResultStatusModel` and `QueryResultPresentation` are, so the whole enable matrix +/// is decidable without mounting a view. The bar used to answer this inline: `disabled(!hasQuery)` +/// written out at four call sites, each with its own idea of what "has a query" meant, and Format +/// with no gate at all. +struct QueryCommandAvailability { + let canRun: Bool + let canStop: Bool + let canExplain: Bool + let canFormat: Bool + let canSaveAsFavorite: Bool + let canClearQuery: Bool + let canClearResults: Bool + let explainVariants: [ExplainVariant] + + /// Every hint the bar shows, resolved here so a disabled control can say why rather than just + /// dimming. A control that dims without explaining is the one thing a reader cannot act on. + let runHint: String + let stopHint: String + let explainHint: String + let formatHint: String + let favoriteHint: String + + init( + isConnected: Bool, + hasQueryText: Bool, + isExecuting: Bool, + hasResults: Bool, + explainVariants: [ExplainVariant], + shortcutHint: (String, ShortcutAction) -> String + ) { + self.explainVariants = explainVariants + canRun = isConnected && hasQueryText && !isExecuting + canStop = isExecuting + canExplain = isConnected && hasQueryText && !isExecuting && !explainVariants.isEmpty + /// Formatting rewrites text the reader already has, so it does not wait for a server. + canFormat = hasQueryText + canSaveAsFavorite = hasQueryText + canClearQuery = hasQueryText + canClearResults = hasResults + + runHint = Self.hint( + base: shortcutHint(String(localized: "Run"), .executeQuery), + reason: Self.blockedReason(isConnected: isConnected, hasQueryText: hasQueryText, isExecuting: isExecuting) + ) + stopHint = shortcutHint(String(localized: "Stop"), .cancelQuery) + explainHint = Self.hint( + base: shortcutHint(String(localized: "Explain"), .explainQuery), + reason: explainVariants.isEmpty + ? String(localized: "This database does not explain statements.") + : Self.blockedReason(isConnected: isConnected, hasQueryText: hasQueryText, isExecuting: isExecuting) + ) + formatHint = Self.hint( + base: shortcutHint(String(localized: "Format"), .formatQuery), + reason: hasQueryText ? nil : String(localized: "There is nothing to format yet.") + ) + favoriteHint = Self.hint( + base: shortcutHint(String(localized: "Save as Favorite"), .saveAsFavorite), + reason: hasQueryText ? nil : String(localized: "There is nothing to save yet.") + ) + } + + private static func blockedReason(isConnected: Bool, hasQueryText: Bool, isExecuting: Bool) -> String? { + if isExecuting { return String(localized: "A query is already running.") } + if !hasQueryText { return String(localized: "There is nothing to run yet.") } + if !isConnected { return String(localized: "This connection is not available.") } + return nil + } + + private static func hint(base: String, reason: String?) -> String { + guard let reason else { return base } + return "\(base)\n\(reason)" + } +} + +/// What the editor bar's leading control names: the container this tab's SQL runs in. +struct QueryScopeBarModel { + let containers: [DatabaseMetadata] + let selectedName: String + let entityName: String + let isReadOnly: Bool + let schemaName: String? +} diff --git a/TablePro/Models/Query/QueryResultPresentation.swift b/TablePro/Models/Query/QueryResultPresentation.swift new file mode 100644 index 0000000000..7bb87f2108 --- /dev/null +++ b/TablePro/Models/Query/QueryResultPresentation.swift @@ -0,0 +1,188 @@ +// +// QueryResultPresentation.swift +// TablePro +// + +import Foundation + +/// What the results pane draws. +/// +/// One value rather than a nest of conditionals. `MainEditorContentView.resultsSection` used to +/// decide this inside its own body: a switch over `ResultsViewMode` whose five arms each repeated +/// the result-set chrome, wrapped around a five-way `if/else if/else` that chose between the +/// success view, an empty `Spacer`, the no-rows view and the grid. Three of those arms tested +/// `lastExecutedAt != nil && !isExecuting` with slightly different companions, so the states were +/// only accidentally exclusive and none of them could be checked without mounting SwiftUI. +enum QueryResultContent: Equatable { + /// Nothing has run and there is nothing to show. + case idle + /// A fetch is in flight with no loaded buffer to draw under it. + case executing + case structure(tableName: String) + case queryPlan + case chart + case map + case json + case grid + /// Columns came back and no rows did, which is a result rather than an absence. + case noRows(executionTime: TimeInterval?) + /// A statement that reports work done rather than rows: INSERT, UPDATE, DDL. + case statementSucceeded(rowsAffected: Int, executionTime: TimeInterval?, statusMessage: String?) + /// The mode draws the loaded buffer and the buffer is empty, so the mode cannot draw. + case unavailable(mode: ResultsViewMode) +} + +/// Everything the results pane needs in order to decide what it is, gathered before any view exists. +/// +/// A plain struct rather than a `QueryTab`, so the whole state matrix is reachable from a test +/// without a tab manager, a coordinator or a session registry behind it. +struct QueryResultInputs: Equatable { + var tabType: TabType = .query + var viewMode: ResultsViewMode = .data + var tableName: String? + var isExecuting = false + var hasExecuted = false + var isExplainResult = false + var hasActiveResultSet = false + var resultSetCount = 0 + /// The active result reported columns. A result with none is a statement that did work. + var activeResultHasColumns = false + var activeResultRowsAffected = 0 + var activeResultExecutionTime: TimeInterval? + var activeResultStatusMessage: String? + var loadedColumnCount = 0 + var loadedRowCount = 0 + var executionErrorMessage: String? + var executionRowsAffected = 0 + var executionTime: TimeInterval? + var executionStatusMessage: String? + var hasAppliedFilters = false + var isFilterPanelVisible = false + var isFindBarVisible = false +} + +/// The whole results pane, resolved from tab state before any view exists. +/// +/// Pure by design, the way `ResultStatusModel` and `ResultSetPolicy` already are: every branch +/// the pane can take is decidable from `QueryResultInputs` alone. +struct QueryResultPresentation: Equatable { + let content: QueryResultContent + /// The result-set chooser in the status bar. One result needs no chooser, and the structure + /// editor is not a result at all. + let showsResultSetSelector: Bool + let showsFilterChrome: Bool + let showsFindBar: Bool + /// Always. A plan used to give the bar up, which was harmless while the deleted strip carried + /// the result chooser above it, and would now leave a plan with no way to be switched away from + /// or pinned. The plan pane's own chrome is a header, so the bar under it is a footer rather + /// than a second header; what the bar gives up for a plan is its row readout, decided in + /// `ResultStatusModel` from `StatusBarSnapshot.isQueryPlan`. + let showsStatusBar: Bool + let showsErrorBanner: Bool + + init(inputs: QueryResultInputs) { + content = Self.resolveContent(inputs) + showsResultSetSelector = Self.resolvesResultSetSelector(inputs) + showsFilterChrome = Self.resolvesFilterChrome(inputs) + showsFindBar = inputs.isFindBarVisible && inputs.tabType == .table + showsStatusBar = true + showsErrorBanner = inputs.executionErrorMessage != nil + } + + private static func resolveContent(_ inputs: QueryResultInputs) -> QueryResultContent { + if inputs.viewMode == .structure { + guard let tableName = inputs.tableName, !tableName.isEmpty else { return .idle } + return .structure(tableName: tableName) + } + + if inputs.isExplainResult { return .queryPlan } + + if inputs.isExecuting, inputs.loadedColumnCount == 0 { return .executing } + + /// Ahead of the idle rule below, because these two modes say why they are empty rather + /// than going blank: a reader who switched to Chart before running anything is told to run + /// something, which is the one thing a blank pane cannot say. + if inputs.viewMode == .chart || inputs.viewMode == .map { + guard inputs.hasActiveResultSet else { return .unavailable(mode: inputs.viewMode) } + } + + /// A query tab that has never run anything has no result to draw. A table tab does: it + /// describes a table whether or not its rows have arrived, which is why the gate names the + /// tab type rather than the buffer. + if inputs.tabType == .query, + !inputs.hasExecuted, + inputs.loadedColumnCount == 0, + inputs.resultSetCount == 0 { + return .idle + } + + if let settled = resolveSettledResult(inputs) { return settled } + + switch inputs.viewMode { + case .chart: + return .chart + case .map: + return .map + case .json: + return .json + case .data: + return .grid + case .structure: + return .idle + default: + return .grid + } + } + + /// The three outcomes a finished execution can have that are not a grid of rows. Answered in one + /// place so they stay mutually exclusive, which they only were by accident while each arm of the + /// old switch retested `hasExecuted && !isExecuting` beside a different companion condition. + private static func resolveSettledResult(_ inputs: QueryResultInputs) -> QueryResultContent? { + guard inputs.hasExecuted, !inputs.isExecuting else { return nil } + + if inputs.hasActiveResultSet, !inputs.activeResultHasColumns, inputs.executionErrorMessage == nil { + return .statementSucceeded( + rowsAffected: inputs.activeResultRowsAffected, + executionTime: inputs.activeResultExecutionTime, + statusMessage: inputs.activeResultStatusMessage + ) + } + + guard inputs.loadedColumnCount == 0 else { return resolveEmptyRows(inputs) } + guard inputs.executionErrorMessage == nil else { return nil } + guard inputs.resultSetCount > 0 else { return .idle } + + return .statementSucceeded( + rowsAffected: inputs.executionRowsAffected, + executionTime: inputs.executionTime, + statusMessage: inputs.executionStatusMessage + ) + } + + /// Columns without rows. A filtered table that filtered everything away keeps the grid, because + /// the filter chrome is what the reader needs in order to get their rows back. + private static func resolveEmptyRows(_ inputs: QueryResultInputs) -> QueryResultContent? { + guard inputs.tabType == .query else { return nil } + guard inputs.loadedRowCount == 0, !inputs.hasAppliedFilters else { return nil } + return .noRows(executionTime: inputs.activeResultExecutionTime ?? inputs.executionTime) + } + + /// The same condition the deleted strip used, deliberately. + /// + /// It would be tempting to hide the chooser at a single result, since its title then says + /// nothing the pane does not. But Pin, Unpin and Close live in its menu, and pinning a result + /// before re-running is exactly the workflow that matters when there is one result on screen. + /// Hiding it there would leave the View menu as the only route to pinning, which is the + /// capability the strip existed to offer. The chooser costs no height to keep: it is a control + /// inside a bar that is already on screen, which is the whole difference from a 32pt band. + private static func resolvesResultSetSelector(_ inputs: QueryResultInputs) -> Bool { + guard inputs.tabType == .query else { return false } + guard inputs.viewMode != .structure else { return false } + return inputs.resultSetCount > 0 + } + + private static func resolvesFilterChrome(_ inputs: QueryResultInputs) -> Bool { + guard inputs.isFilterPanelVisible, inputs.tabType == .table else { return false } + return inputs.viewMode.showsRowFilters + } +} diff --git a/TablePro/Models/Query/ResultSetMenuModel.swift b/TablePro/Models/Query/ResultSetMenuModel.swift new file mode 100644 index 0000000000..c7495a269e --- /dev/null +++ b/TablePro/Models/Query/ResultSetMenuModel.swift @@ -0,0 +1,67 @@ +// +// ResultSetMenuModel.swift +// TablePro +// + +import Foundation + +/// One entry in the result-set chooser. +struct ResultSetMenuEntry: Equatable, Identifiable { + let id: UUID + let label: String + let isPinned: Bool + let isActive: Bool + /// What the entry is called when the reader is counting rather than reading: "Result 2 of 4". + let ordinal: Int +} + +/// The result-set chooser, resolved before any view exists. +/// +/// Replaces the 32pt strip of hand-drawn tabs. macOS has no native tab control that closes, pins or +/// reorders anything but windows: `NSTabViewItem` carries ten properties and not one of them is a +/// close affordance, `NSWindowTabGroup` takes `NSWindow` only, and SwiftUI's `Tab` and `TabSection` +/// are macOS 15 with no close either. So the collection is re-expressed rather than redrawn, and +/// the HIG names the control to re-express it with: a pop-up button is the "reasonable alternative +/// in cases where there are too many panes" for a tab view, and result sets are unbounded. +/// +/// It is absent at one result, which is the common case and the strip's worst habit: a lone tab +/// spending a band of height to say "this is the result". +struct ResultSetMenuModel: Equatable { + let entries: [ResultSetMenuEntry] + let activeOrdinal: Int + let total: Int + + var isEmpty: Bool { entries.isEmpty } + + /// The button's own words. A closed menu hides the count that the strip showed at a glance, so + /// the title carries it: this is the whole mitigation for the one thing the strip did better. + var title: String { + guard total > 1 else { return entries.first?.label ?? "" } + return String( + format: String(localized: "Result %1$d of %2$d"), + activeOrdinal, + total + ) + } + + /// The same count in figures, for the tiers where the bar has no room for the sentence. The + /// chooser never leaves the bar entirely, because Pin and Close have no other one-click route. + var compactTitle: String { + guard total > 1 else { return entries.first?.label ?? "" } + return "\(activeOrdinal)/\(total)" + } + + var activeEntry: ResultSetMenuEntry? { + entries.first { $0.isActive } + } + + /// Whether closing this entry is offered. A pinned result is pinned precisely so that nothing + /// takes it away, which the strip enforced by withholding its close button. + func canClose(_ entry: ResultSetMenuEntry) -> Bool { + !entry.isPinned + } + + func canCloseOthers(_ entry: ResultSetMenuEntry) -> Bool { + entries.contains { $0.id != entry.id && !$0.isPinned } + } +} diff --git a/TablePro/Models/Query/ResultSetPolicy.swift b/TablePro/Models/Query/ResultSetPolicy.swift new file mode 100644 index 0000000000..4c2e1ca4b9 --- /dev/null +++ b/TablePro/Models/Query/ResultSetPolicy.swift @@ -0,0 +1,22 @@ +// +// ResultSetPolicy.swift +// TablePro +// +// Decides when a result can be pinned. The View menu and the result-set chooser both ask here, +// so the command and the control it mirrors never disagree. +// + +import Foundation + +enum ResultSetPolicy { + /// Whether this tab holds a result that pinning means anything for. + /// + /// This used to be `showsTabBar`, and the strip it named is gone: `QueryResultPresentation` + /// owns the question of what the results pane draws. What survives is the narrower question of + /// whether there is an active result to hold on to. + static func canPin(tabType: TabType, display: TabDisplayState) -> Bool { + guard tabType == .query else { return false } + guard display.resultsViewMode != .structure else { return false } + return display.activeResultSet != nil + } +} diff --git a/TablePro/Models/Query/ResultStatusModel.swift b/TablePro/Models/Query/ResultStatusModel.swift index e37ca0be1a..8fe37a1f4f 100644 --- a/TablePro/Models/Query/ResultStatusModel.swift +++ b/TablePro/Models/Query/ResultStatusModel.swift @@ -90,6 +90,11 @@ struct ResultStatusModel: Equatable { controls.showsModeSwitcher = snapshot.availableModes.count > 1 controls.showsStructureActions = viewMode == .structure && snapshot.hasStructureActions + /// A plan keeps the bar so it stays choosable and pinnable, and gives up everything the bar + /// says about rows. It has none, and reporting "No rows" under a plan states something + /// false about the statement that produced it. + guard !snapshot.isQueryPlan else { return controls } + /// A table tab describes a table whether or not its rows have arrived, so its controls are /// decided by what the tab IS, never by what its buffer currently holds. Retargeting empties /// that buffer before the replacing fetch starts, and deriving presence from it made the diff --git a/TablePro/Models/Query/ResultStatusPresentation.swift b/TablePro/Models/Query/ResultStatusPresentation.swift index 62c43d3a20..c702ed0ca3 100644 --- a/TablePro/Models/Query/ResultStatusPresentation.swift +++ b/TablePro/Models/Query/ResultStatusPresentation.swift @@ -48,6 +48,10 @@ struct ResultStatusPresentation: Equatable { let showsEdgePageButtons: Bool /// Whether rows-per-page stands beside the page indicator or moves inside its menu. let pageSizeIsInline: Bool + /// Whether the result-set chooser spells its title out ("Result 2 of 4") or counts in figures + /// ("2/4"). It never leaves the bar: Pin and Close live in its menu and have no other one-click + /// route, which is the same reason nothing that opens a popover may leave. + let resultSetMenuIsSpelledOut: Bool init(tier: StatusBarTier) { switch tier { @@ -56,16 +60,19 @@ struct ResultStatusPresentation: Equatable { modeSwitcherIsSegmented = true showsEdgePageButtons = true pageSizeIsInline = true + resultSetMenuIsSpelledOut = true case .compact: showsControlTitles = false modeSwitcherIsSegmented = true showsEdgePageButtons = false pageSizeIsInline = true + resultSetMenuIsSpelledOut = false case .narrow: showsControlTitles = false modeSwitcherIsSegmented = false showsEdgePageButtons = false pageSizeIsInline = false + resultSetMenuIsSpelledOut = false } } } diff --git a/TablePro/Models/Query/ResultTabBarPolicy.swift b/TablePro/Models/Query/ResultTabBarPolicy.swift deleted file mode 100644 index 6dfbffe53b..0000000000 --- a/TablePro/Models/Query/ResultTabBarPolicy.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// 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.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/Models/Query/StatusBarSnapshot.swift b/TablePro/Models/Query/StatusBarSnapshot.swift index 0db43c8fac..54a615d491 100644 --- a/TablePro/Models/Query/StatusBarSnapshot.swift +++ b/TablePro/Models/Query/StatusBarSnapshot.swift @@ -26,6 +26,9 @@ struct StatusBarSnapshot: Equatable { let pagination: PaginationState let statusMessage: String? let paginationCapability: PaginationCapability + /// A query plan is a result set, so it is chosen and pinned from the bar like any other, but it + /// has no rows and no pages. Without this the bar reports "No rows" under every plan. + let isQueryPlan: Bool init( tabId: UUID?, @@ -38,6 +41,7 @@ struct StatusBarSnapshot: Equatable { hasTableName: Bool, availableModes: [ResultsViewMode] = [], hasStructureActions: Bool = false, + isQueryPlan: Bool = false, pagination: PaginationState, statusMessage: String?, paginationCapability: PaginationCapability = .offset @@ -52,6 +56,7 @@ struct StatusBarSnapshot: Equatable { self.hasTableName = hasTableName self.availableModes = availableModes self.hasStructureActions = hasStructureActions + self.isQueryPlan = isQueryPlan self.pagination = pagination self.statusMessage = statusMessage self.paginationCapability = paginationCapability @@ -68,6 +73,7 @@ struct StatusBarSnapshot: Equatable { displayRowCount: Int? = nil, isFetching: Bool = false, hasStructureActions: Bool = false, + isQueryPlan: Bool = false, paginationCapability: PaginationCapability = .offset ) { let loaded = tableRows?.rows.count ?? 0 @@ -90,6 +96,7 @@ struct StatusBarSnapshot: Equatable { hasSpatialColumn: !(tab?.display.spatialColumns.isEmpty ?? true) ), hasStructureActions: hasStructureActions, + isQueryPlan: isQueryPlan, pagination: pagination, statusMessage: tab?.execution.statusMessage, paginationCapability: paginationCapability diff --git a/TablePro/Models/UI/TrailingPaneSurface.swift b/TablePro/Models/UI/TrailingPaneSurface.swift index c8288b8e4c..7b31c4164e 100644 --- a/TablePro/Models/UI/TrailingPaneSurface.swift +++ b/TablePro/Models/UI/TrailingPaneSurface.swift @@ -20,11 +20,18 @@ import Foundation internal enum TrailingPaneSurface: String, CaseIterable, Hashable { case inspector case assistant + /// Query history, which used to be a third band stacked under the editor and the results. + /// + /// It is a peer of the other two by the same argument they are peers of each other: it is a + /// task surface no selection owns. Its panel stacks its list over its detail rather than + /// beside it, so it reads at the shared 270pt floor without moving it. + case history internal var localizedTitle: String { switch self { case .inspector: String(localized: "Inspector") case .assistant: String(localized: "Assistant") + case .history: String(localized: "Query History") } } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 753d1f8590..820f854e53 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -682,6 +682,9 @@ }, "“%@” is no longer at that location." : { + }, + "\"%@\" is not a valid AWS region." : { + }, "“%@” is still in use" : { @@ -1971,6 +1974,9 @@ } } } + }, + "%@ (reader)" : { + }, "%@ %@" : { "localizations" : { @@ -2335,6 +2341,9 @@ }, "%@ cannot start with a dash or contain spaces" : { + }, + "%@ changed after it was compared. Compare again before generating the script." : { + }, "%@ completed" : { "localizations" : { @@ -3849,6 +3858,16 @@ } } }, + "%@, %@, %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@, %2$@, %3$@" + } + } + } + }, "%@, finished" : { "localizations" : { "ko" : { @@ -3884,6 +3903,7 @@ } }, "%@, pinned" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -4602,6 +4622,12 @@ }, "%1$@ (%2$lld of %3$lld), %4$lld rows" : { + }, + "%1$@ (%2$lld)" : { + + }, + "%1$@ (Copy %2$lld)" : { + }, "%1$@ (ORA-%2$ld)." : { "extractionState" : "stale", @@ -4637,6 +4663,9 @@ } } } + }, + "%1$@ (reader, %2$@)" : { + }, "%1$@ %2$@" : { @@ -5476,6 +5505,9 @@ } } } + }, + "%1$d of %2$d listed differences included" : { + }, "%1$d of %2$d rows, %3$d marked for deletion" : { "localizations" : { @@ -5544,6 +5576,12 @@ } } } + }, + "%1$d rows match. The first %2$d are listed." : { + + }, + "%1$d rows use %2$@, which cannot be drawn." : { + }, "%1$d statements stay out of this run and %2$@ keeps what it has for them." : { "extractionState" : "stale", @@ -6013,6 +6051,7 @@ } }, "%d bytes" : { + "extractionState" : "stale", "localizations" : { "en" : { "variations" : { @@ -6328,6 +6367,9 @@ }, "%d could not be read, so no pair could be made." : { + }, + "%d databases" : { + }, "%d deleted" : { "localizations" : { @@ -7005,6 +7047,9 @@ } } } + }, + "%d regions could not be searched." : { + }, "%d rows" : { "localizations" : { @@ -7039,6 +7084,21 @@ } } } + }, + "%d rows are past the drawing limit." : { + + }, + "%d rows could not be read back from the source by key. Compare again." : { + + }, + "%d rows could not be read back from the target by key. Compare again." : { + + }, + "%d rows could not be read." : { + + }, + "%d rows exist on the other side outside its filter. They are never written. Widen the filter to sync them." : { + }, "%d rows hold NULL in a key column and were left out. Choose a key with no NULLs to compare them." : { "localizations" : { @@ -7091,8 +7151,12 @@ } } } + }, + "%d rows in other coordinate systems are not drawn." : { + }, "%d rows match. Matching rows are counted, not listed, so a difference is never crowded out of this list." : { + "extractionState" : "stale", "localizations" : { "en" : { "variations" : { @@ -7146,6 +7210,9 @@ }, "%d rows selected" : { + }, + "%d rows selected." : { + }, "%d rows will be restored" : { "localizations" : { @@ -7185,7 +7252,6 @@ }, "%d selected" : { - "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -7252,6 +7318,19 @@ } } } + }, + "%d statements failed, so the run was rolled back and %@ is unchanged." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d statements failed, so the run was rolled back and %2$@ is unchanged." + } + } + } + }, + "%d statements failed. What already ran stays applied. Compare again to see where the target stands." : { + }, "%d statements would destroy data and are not allowed yet." : { "localizations" : { @@ -7289,6 +7368,9 @@ }, "%d statements would destroy data. Allow them or exclude them before applying." : { + }, + "%d values in this column are not in a format the map can read. The grid still shows them as the database returned them." : { + }, "%d-%d of ? rows" : { "extractionState" : "stale", @@ -7634,8 +7716,12 @@ } } } + }, + "%lld connection(s) use this profile. They keep this configuration as their own SSH tunnel." : { + }, "%lld connection(s) use this profile. They will fall back to no SSH tunnel." : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -9256,6 +9342,9 @@ } } } + }, + "^[%lld connection](inflect: true) use this profile. They keep these credentials as their own." : { + }, "© 2026 Ngo Quoc Dat.\n%@" : { "extractionState" : "stale", @@ -9430,6 +9519,9 @@ } } } + }, + "~/.pgpass" : { + }, "~/.pgpass found — matching entry exists" : { "extractionState" : "stale", @@ -9949,6 +10041,9 @@ } } } + }, + "1 database" : { + }, "1 day" : { "localizations" : { @@ -11291,8 +11386,12 @@ } } } + }, + "A column left out of the comparison is still written on insert and update. A filter and a row limit read only the rows they select, on both sides." : { + }, "A column left out of the comparison is still written on insert and update. Changing either list needs another comparison." : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -11572,6 +11671,9 @@ } } } + }, + "A connection reads only the secrets its own database type declares, so one profile can carry several engines' keys." : { + }, "A connection with this name, host, and type already exists." : { "localizations" : { @@ -11609,6 +11711,9 @@ }, "A copy to another engine needs a type system TablePro can translate, and %@ has none it knows. Copy to a target of the same type." : { + }, + "A data comparison reads tables only." : { + }, "A Data Pump job for %1$@ was started, writing to %2$@. Watch DBA_DATAPUMP_JOBS for its progress." : { @@ -11686,9 +11791,21 @@ }, "A dump of chosen tables may not restore on its own. Sequences, types, schemas and tables it references are left out." : { + }, + "A file" : { + + }, + "A filter cannot contain a comment." : { + + }, + "A filter is a single condition and cannot contain a semicolon." : { + }, "A filter is one expression. Remove the semicolon." : { + }, + "A filtered comparison needs both sides to look up rows by key." : { + }, "A foreign key change recreates the table, and these cannot travel with it:\n\n%@\n\nSave them on their own first, then change the foreign key." : { @@ -11809,6 +11926,9 @@ }, "A Kubernetes resource is required, such as service/postgres" : { + }, + "A map needs a geometry or geography column. This result has none." : { + }, "A materialized view" : { @@ -11849,6 +11969,9 @@ } } } + }, + "A new version downloads in the background and installs the next time you quit TablePro." : { + }, "A newer TablePro is required to load this plugin." : { "localizations" : { @@ -11988,6 +12111,9 @@ } } } + }, + "A quote or parenthesis in this filter is not closed." : { + }, "A row with this key already exists" : { "localizations" : { @@ -12195,6 +12321,9 @@ } } } + }, + "A SQL condition, such as status = 'active'" : { + }, "A statement on '%1$@' matched %2$d rows instead of %3$d, so nothing was saved." : { "localizations" : { @@ -14465,6 +14594,9 @@ } } } + }, + "Add Credential Profile…" : { + }, "Add Custom Provider…" : { "localizations" : { @@ -15140,6 +15272,9 @@ }, "Add Rule" : { + }, + "Add SSH Server…" : { + }, "Add tags" : { "localizations" : { @@ -15416,6 +15551,9 @@ } } } + }, + "Additional Secrets" : { + }, "Address" : { "localizations" : { @@ -16576,6 +16714,9 @@ } } } + }, + "Alias Type" : { + }, "All" : { "localizations" : { @@ -17193,6 +17334,9 @@ } } } + }, + "All Selected Tags" : { + }, "All tables and data will be permanently deleted." : { "localizations" : { @@ -17542,6 +17686,9 @@ } } } + }, + "Allow explicit identity values in %@" : { + }, "Allow remote connections" : { "extractionState" : "stale", @@ -18380,6 +18527,9 @@ }, "An enum's labels in declaration order" : { + }, + "An environment variable" : { + }, "an explain" : { "localizations" : { @@ -19083,6 +19233,9 @@ } } } + }, + "Another profile already has this name." : { + }, "ANTHROPIC_API_KEY and ANTHROPIC_AUTH_TOKEN are removed from the tool's environment, so replies always draw on the subscription." : { "localizations" : { @@ -19253,6 +19406,9 @@ } } } + }, + "Any Selected Tag" : { + }, "API Key" : { "localizations" : { @@ -19532,6 +19688,9 @@ } } } + }, + "app_reader" : { + }, "Appearance" : { "localizations" : { @@ -21214,6 +21373,9 @@ } } } + }, + "As a Read-Only Copy" : { + }, "As Copy" : { "localizations" : { @@ -21555,6 +21717,9 @@ } } } + }, + "Ask every time" : { + }, "Ask Every Time" : { @@ -21702,6 +21867,9 @@ }, "Assistant" : { + }, + "Assumes a role from its source profile." : { + }, "asterisk" : { "extractionState" : "manual", @@ -23220,6 +23388,12 @@ } } } + }, + "AWS IAM" : { + + }, + "AWS is throttling requests in %@. Try again shortly." : { + }, "AWS managed key-value/document store" : { "localizations" : { @@ -23254,6 +23428,9 @@ } } } + }, + "AWS Profile" : { + }, "AWS Region" : { "localizations" : { @@ -23294,6 +23471,15 @@ } } } + }, + "AWS rejected the request signature because this Mac's clock is too far off. Check Date & Time in System Settings." : { + + }, + "AWS rejected the request signature for this profile's credentials." : { + + }, + "AWS rejected these credentials. Check the profile in ~/.aws/config." : { + }, "AWS SSM Session" : { @@ -24902,6 +25088,9 @@ } } } + }, + "Building map" : { + }, "Built-in" : { "localizations" : { @@ -25903,6 +26092,9 @@ } } } + }, + "Cancel the running query" : { + }, "Cancel the running query on a server session, or kill the session outright. Takes a process id from get_server_dashboard. Needs tools:write and the user's approval." : { "localizations" : { @@ -27444,6 +27636,7 @@ } }, "Change Color" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -27545,6 +27738,9 @@ } } } + }, + "Change Not Saved" : { + }, "Change Password" : { "localizations" : { @@ -30125,6 +30321,9 @@ } } } + }, + "Choose which result this pane shows" : { + }, "Choose which rows of this table are copied" : { @@ -30748,9 +30947,6 @@ } } } - }, - "Clear Filter" : { - }, "Clear Filters" : { "localizations" : { @@ -30956,6 +31152,9 @@ } } } + }, + "Clear Recent" : { + }, "Clear Recent Tables" : { "localizations" : { @@ -32512,6 +32711,9 @@ } } } + }, + "Close Other Results" : { + }, "Close Other Tabs" : { "localizations" : { @@ -32548,6 +32750,7 @@ } }, "Close Others" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -32649,8 +32852,12 @@ } } } + }, + "Close Result" : { + }, "Close result tab" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -33606,6 +33813,9 @@ } } } + }, + "CLR Type" : { + }, "Cluster" : { "localizations" : { @@ -35943,6 +36153,9 @@ } } } + }, + "Compare again, or exclude the tables not compared yet: %@." : { + }, "Compare lists the tables both sides share. Choose the tables to compare, then press Compare." : { "extractionState" : "stale", @@ -36184,6 +36397,19 @@ } } }, + "Compared %@ keys in key order. Rows past the limit were not read on either side, and rows with NULL in a key column are not read under a limit." : { + + }, + "Compared %@. %d differences." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Compared %1$@. %2$d differences." + } + } + } + }, "Compared %@. %d differences. Nothing has been written." : { "localizations" : { "en" : { @@ -36223,6 +36449,9 @@ } } } + }, + "Compared %@. 1 difference." : { + }, "Compared %@. 1 difference. Nothing has been written." : { "localizations" : { @@ -36257,6 +36486,9 @@ } } } + }, + "Compared %1$@ keys in key order, up to %2$@. Filter both sides past that key for the next rows. NULL keys are not read under a limit." : { + }, "Compared columns" : { "localizations" : { @@ -36291,6 +36523,9 @@ } } } + }, + "Comparing only. Changes applied earlier stay in the target." : { + }, "Comparing only. Nothing has been written." : { "localizations" : { @@ -37555,7 +37790,6 @@ } }, "Connecting" : { - "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -37832,6 +38066,9 @@ } } } + }, + "Connection error" : { + }, "connection failed" : { "localizations" : { @@ -42015,6 +42252,12 @@ } } } + }, + "Could not delete the profile. Check disk space and permissions, then try again." : { + + }, + "Could not delete the profile. Reopen the SSH settings and try again." : { + }, "Could not encode image" : { "localizations" : { @@ -42637,6 +42880,9 @@ } } } + }, + "Could not reach the AWS RDS API: %@" : { + }, "Could not reach the license server. Check your internet connection and try again." : { "localizations" : { @@ -42853,6 +43099,9 @@ } } } + }, + "Could not save a secret to the Keychain. Nothing was changed." : { + }, "Could not save the connection. Check disk space and permissions, then try again." : { "localizations" : { @@ -42887,6 +43136,15 @@ } } } + }, + "Could not save the password to the Keychain. Nothing was changed." : { + + }, + "Could not save the profile. Check disk space and permissions, then try again." : { + + }, + "Could not save the profile. Reopen the SSH settings and try again." : { + }, "Could not search the referenced table" : { @@ -43244,6 +43502,9 @@ } } } + }, + "Couldn't read the referenced table" : { + }, "Couldn't Refresh Materialized View" : { @@ -44634,6 +44895,9 @@ } } } + }, + "Credential Profiles" : { + }, "Credentials" : { "localizations" : { @@ -45234,6 +45498,9 @@ } } } + }, + "Current connection" : { + }, "Current database" : { "comment" : "A description of a database that is currently selected.", @@ -48424,6 +48691,9 @@ }, "Databases and objects to back up" : { + }, + "Databases found in AWS" : { + }, "Databases of %@" : { "localizations" : { @@ -48596,6 +48866,7 @@ } }, "db %d" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -49042,6 +49313,7 @@ } }, "Decrease Text Size" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -50042,6 +50314,9 @@ } } } + }, + "Delete %d Connections…" : { + }, "Delete %lld columns?" : { "localizations" : { @@ -50299,6 +50574,12 @@ } } } + }, + "Delete Connection…" : { + + }, + "Delete Credential Profile?" : { + }, "Delete existing rows first" : { @@ -50472,6 +50753,9 @@ } } } + }, + "Delete Group…" : { + }, "Delete Index" : { "localizations" : { @@ -53104,6 +53388,9 @@ } } } + }, + "Distributed HTAP, MySQL-compatible" : { + }, "Distributed key-value store for service discovery" : { "localizations" : { @@ -53859,6 +54146,9 @@ } } } + }, + "Download and install updates automatically" : { + }, "Download cloud-sql-proxy…" : { "localizations" : { @@ -54040,6 +54330,15 @@ } } } + }, + "Drawing %1$d shapes in SRID %2$d." : { + + }, + "Drawing %d shapes with no SRID, read as longitude and latitude." : { + + }, + "Drawing %d shapes." : { + }, "Driver Options" : { "comment" : "A section for driver-specific options.", @@ -58821,6 +59120,7 @@ } }, "Enter a new name for the group." : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -58887,6 +59187,12 @@ } } } + }, + "Enter Below" : { + + }, + "Enter Credentials Here" : { + }, "Enter database name" : { "extractionState" : "stale", @@ -59412,9 +59718,6 @@ }, "Enum Type" : { - }, - "enum, composite, domain or range" : { - }, "Enumerated values the column accepts" : { "localizations" : { @@ -60394,9 +60697,18 @@ }, "Every column is written when none is ticked." : { + }, + "Every connection using this profile asks for the password once each time TablePro runs." : { + }, "Every database you picked is written into this folder." : { + }, + "Every difference in the included tables is switched off in Options or excluded row by row." : { + + }, + "Every geometry in this column is empty or null." : { + }, "Every included column needs a name." : { "localizations" : { @@ -60988,6 +61300,9 @@ } } } + }, + "Exclude Every Listed Row" : { + }, "Exclude Every Table" : { "localizations" : { @@ -61231,6 +61546,9 @@ } } } + }, + "Execute a query to map its loaded rows." : { + }, "Execute a query to view results as JSON" : { "localizations" : { @@ -61508,6 +61826,7 @@ } }, "Execute Without Limit" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -66920,6 +67239,7 @@ } }, "Favorited" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -66986,6 +67306,9 @@ } } } + }, + "FAVORITES" : { + }, "Feature Routing" : { "extractionState" : "stale", @@ -68092,6 +68415,9 @@ } } } + }, + "Filter by Tag" : { + }, "Filter by text or /regex/" : { @@ -68578,6 +68904,9 @@ } } } + }, + "Filter regions" : { + }, "Filter roles" : { "localizations" : { @@ -68923,6 +69252,9 @@ } } } + }, + "Filtered, first %@" : { + }, "Filtering by only this row" : { "extractionState" : "stale", @@ -69366,6 +69698,12 @@ } } } + }, + "First" : { + + }, + "First %@" : { + }, "First %d" : { @@ -69474,6 +69812,9 @@ }, "First row" : { + }, + "Fit to Result" : { + }, "Fit to Window" : { "localizations" : { @@ -69577,6 +69918,9 @@ } } } + }, + "Fix the settings for, or exclude, %@." : { + }, "Fix with AI" : { "localizations" : { @@ -70172,6 +70516,9 @@ } } } + }, + "For" : { + }, "For a metered path on Anthropic's commercial terms, add the Claude provider with an API key instead." : { "localizations" : { @@ -71613,6 +71960,9 @@ } } } + }, + "Geometry Column" : { + }, "Get Connection Status" : { "localizations" : { @@ -72966,6 +73316,7 @@ } }, "Group Not Updated" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -76300,6 +76651,12 @@ } } } + }, + "Import from AWS" : { + + }, + "Import from AWS…" : { + }, "Import from Codex CLI" : { "localizations" : { @@ -76791,6 +77148,12 @@ } } } + }, + "Imported connections ask for a password on first connect." : { + + }, + "Imported connections use AWS IAM where the database has it enabled, and ask for a password where it does not." : { + }, "Importing passwords from %1$@ reads up to %2$d keychain items. macOS prompts for your login password once per item because each is owned by %1$@. Click Always Allow on each prompt to grant TablePro permanent access. Cancel any prompt to skip the rest." : { "localizations" : { @@ -76927,6 +77290,9 @@ } } } + }, + "in key order" : { + }, "in list" : { "localizations" : { @@ -77373,6 +77739,9 @@ } } } + }, + "Include Every Listed Row" : { + }, "Include every object in this group" : { "localizations" : { @@ -77682,6 +78051,7 @@ } }, "Include this row" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -77888,6 +78258,7 @@ } }, "Increase Text Size" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -81211,6 +81582,9 @@ } } } + }, + "JSON Key" : { + }, "JSON Too Large" : { "localizations" : { @@ -82118,6 +82492,9 @@ } } } + }, + "Key %1$@ matches more than one row in the %2$@. Choose key columns that identify a single row." : { + }, "Key column %@ is not present on both sides. Choose a different key." : { "localizations" : { @@ -83189,6 +83566,15 @@ } } } + }, + "Last checked: %@" : { + + }, + "Last checked: never" : { + + }, + "Last Connected" : { + }, "Last Hour" : { "localizations" : { @@ -87026,7 +87412,7 @@ } } }, - "List the user-defined types in a schema: enums, composites, domains and ranges." : { + "List the user-defined types in a schema, whichever shapes the engine has." : { }, "List the users and roles defined on the server, with their attributes and role memberships." : { @@ -87204,6 +87590,9 @@ }, "Listing the tables both sides share." : { + }, + "Lists system databases such as mysql and information_schema, and system schemas, in the sidebar tree and the database filter. Switchers always list them." : { + }, "Load" : { "localizations" : { @@ -88518,6 +88907,9 @@ } } } + }, + "Looked up in ~/.pgpass by the host, port, database and username of whichever connection is opening." : { + }, "Looking for database settings…" : { "localizations" : { @@ -88552,6 +88944,9 @@ } } } + }, + "Looking for RDS instances and Aurora clusters" : { + }, "Looks like a placeholder value" : { "localizations" : { @@ -89209,6 +89604,9 @@ } } } + }, + "Manage Profiles…" : { + }, "Manage Tags" : { "localizations" : { @@ -89311,6 +89709,12 @@ } } } + }, + "Map" : { + + }, + "Map of %1$d shapes from %2$d rows." : { + }, "Markdown" : { "localizations" : { @@ -89386,6 +89790,9 @@ } } } + }, + "Match" : { + }, "Match all" : { "localizations" : { @@ -89422,6 +89829,7 @@ } }, "Match All" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -89559,6 +89967,7 @@ } }, "Match Any" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -92184,6 +92593,9 @@ } } } + }, + "Missing Profile" : { + }, "Missing required parameter: %@" : { "localizations" : { @@ -92776,6 +93188,9 @@ }, "Move Column Up" : { + }, + "Move Connections" : { + }, "Move Down" : { "localizations" : { @@ -93050,6 +93465,9 @@ } } } + }, + "Move Groups" : { + }, "Move Line Down" : { "localizations" : { @@ -93671,6 +94089,7 @@ } }, "Moves the editor cursor to the statement that produced this result" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -97503,6 +97922,9 @@ } } } + }, + "No credential profiles yet." : { + }, "No credentials are sent. Use this when the server handles authentication itself, such as a Tailscale SSH host." : { "localizations" : { @@ -100557,6 +100979,9 @@ } } } + }, + "No RDS instances or Aurora clusters in %@." : { + }, "No remote database file was named for this connection." : { "localizations" : { @@ -101422,9 +101847,15 @@ } } } + }, + "No Spatial Column" : { + }, "No SSH agent answered on the socket from %@. Check that agent is running." : { + }, + "No SSH servers yet." : { + }, "No SSL encryption" : { "localizations" : { @@ -102365,6 +102796,9 @@ } } } + }, + "None selected" : { + }, "Normal" : { "localizations" : { @@ -102683,6 +103117,7 @@ } }, "Not connected to ClickHouse" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -103548,6 +103983,9 @@ } } } + }, + "Not synced to iCloud" : { + }, "Not visible" : { "localizations" : { @@ -103805,6 +104243,9 @@ }, "Nothing to Copy" : { + }, + "Nothing to Draw" : { + }, "Nothing to Restore" : { "localizations" : { @@ -105400,6 +105841,9 @@ } } } + }, + "On the Server" : { + }, "On Update" : { "localizations" : { @@ -105468,6 +105912,9 @@ } } } + }, + "One bastion, many connections. Editing the server or its credentials reaches every connection tunnelling through it." : { + }, "One element per row. Use where a parser expects XML." : { @@ -105573,6 +106020,9 @@ } } } + }, + "One username and password, shared by any number of connections. Change it here and every connection using it signs in with the new one." : { + }, "Only %1$d of %2$d bytes of %3$@ arrived." : { "localizations" : { @@ -105743,6 +106193,12 @@ } } } + }, + "only in the source" : { + + }, + "only in the target" : { + }, "Only the first %1$lld nodes were loaded, so this value was not searched in full. Switch to %2$@ to search all of it." : { "localizations" : { @@ -108555,6 +109011,9 @@ } } } + }, + "Optimize Table" : { + }, "Optimize table (merge parts)" : { "localizations" : { @@ -109590,6 +110049,9 @@ } } } + }, + "Outside Filter" : { + }, "Over an SSH tunnel, TablePro connects directly to the first host. Replica set failover is not available." : { "extractionState" : "stale", @@ -111216,6 +111678,9 @@ } } } + }, + "Password source" : { + }, "Password:" : { "localizations" : { @@ -111394,6 +111859,9 @@ } } } + }, + "Past queries appear once the connection is up" : { + }, "Paste" : { "localizations" : { @@ -112289,6 +112757,9 @@ } } } + }, + "Pick a profile from ~/.aws/config or ~/.aws/credentials." : { + }, "Pick the type of database you want to connect to." : { "localizations" : { @@ -113705,6 +114176,9 @@ } } } + }, + "Point at latitude %1$.5f, longitude %2$.5f" : { + }, "Point at the socket file itself, not the directory holding it." : { "localizations" : { @@ -116648,6 +117122,9 @@ } } } + }, + "Production reader" : { + }, "Profile" : { "localizations" : { @@ -116803,6 +117280,9 @@ } } } + }, + "Profiles" : { + }, "Progress estimated (%d table(s) could not be counted)" : { "extractionState" : "stale", @@ -120477,6 +120957,12 @@ } } } + }, + "Read From" : { + + }, + "Read from elsewhere" : { + }, "Read Only" : { "localizations" : { @@ -121328,7 +121814,6 @@ } }, "RECENT" : { - "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -122117,6 +122602,9 @@ } } } + }, + "Reference" : { + }, "referenced by %@" : { @@ -122833,6 +123321,9 @@ }, "Region" : { + }, + "Regions" : { + }, "Registry" : { "localizations" : { @@ -122975,6 +123466,9 @@ }, "Release File Lock" : { + }, + "Release notes are on the changelog." : { + }, "Release the File Lock After (minutes, 0 to keep it)" : { @@ -123465,6 +123959,9 @@ } } } + }, + "Remote SQLite session disconnected. Click to reconnect." : { + }, "Remove" : { "localizations" : { @@ -124854,6 +125351,7 @@ } }, "Rename Group" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -125179,6 +125677,9 @@ }, "Reorder Columns" : { + }, + "Reorder Favorites" : { + }, "Replace" : { "localizations" : { @@ -126280,6 +126781,9 @@ } } } + }, + "Resolved on every connect. It stays on this Mac and never syncs, because it can name a command to run." : { + }, "Resource" : { "localizations" : { @@ -127397,6 +127901,9 @@ } } } + }, + "Result %1$d of %2$d" : { + }, "Result %d" : { "localizations" : { @@ -129711,6 +130218,9 @@ } } } + }, + "Rows in %@ changed after they were compared. Compare again before generating the script." : { + }, "Rows in this export" : { "localizations" : { @@ -130381,6 +130891,9 @@ } } } + }, + "Run All Statements" : { + }, "Run button beside each statement" : { "localizations" : { @@ -130619,6 +131132,9 @@ } } } + }, + "Run Query" : { + }, "Run Script" : { "localizations" : { @@ -130931,6 +131447,9 @@ } } } + }, + "Run Without Limit" : { + }, "Running on port %d" : { "localizations" : { @@ -131043,6 +131562,9 @@ }, "Runs on '%@'." : { + }, + "Runs statements on the SSH server so a database file there can be read and written in place." : { + }, "Runs the claude command line tool so chat bills against your Claude subscription. Database tools need the MCP server turned on in Settings > Integrations." : { "localizations" : { @@ -131080,6 +131602,9 @@ }, "Runs the Google Cloud SQL Auth Proxy against an instance connection name." : { + }, + "Runs the profile's credential_process command." : { + }, "Safe Mode" : { "localizations" : { @@ -132371,6 +132896,9 @@ } } } + }, + "Save These as a Profile…" : { + }, "Save this comparison" : { @@ -132590,6 +133118,12 @@ } } } + }, + "Saved in the Keychain" : { + + }, + "Saved password" : { + }, "Saved passwords are decrypted during import" : { "localizations" : { @@ -135260,6 +135794,9 @@ } } } + }, + "Searching…" : { + }, "Seats on a team license are managed on tablepro.app." : { "localizations" : { @@ -135543,6 +136080,9 @@ } } } + }, + "Secret ID" : { + }, "Section Header" : { "localizations" : { @@ -140634,6 +141174,9 @@ } } } + }, + "Show system databases and schemas" : { + }, "Show System Schemas" : { "extractionState" : "stale", @@ -141336,6 +141879,9 @@ }, "Shows how the time was spent" : { + }, + "Side" : { + }, "Sidebar" : { "localizations" : { @@ -142366,6 +142912,12 @@ } } } + }, + "Signs in with a web identity token, which is not supported yet." : { + + }, + "Signs in with IAM Identity Center." : { + }, "Silent" : { "localizations" : { @@ -142745,6 +143297,12 @@ } } } + }, + "Skipped %@: no endpoint yet." : { + + }, + "Skipped %@: TablePro has no driver for that engine." : { + }, "Skipped settings this server does not recognize: %@." : { "localizations" : { @@ -143398,6 +143956,9 @@ } } } + }, + "Some included tables have not been compared with their current settings." : { + }, "Some passwords were not read. You can enter them in the connection editor after import." : { "localizations" : { @@ -143434,6 +143995,7 @@ } }, "Some tables have not been compared with the columns now chosen." : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -143637,6 +144199,9 @@ } } } + }, + "Sort and filter connections" : { + }, "Sort Ascending" : { "localizations" : { @@ -143671,6 +144236,9 @@ } } } + }, + "Sort By" : { + }, "Sort columns in priority order" : { "localizations" : { @@ -143705,6 +144273,9 @@ } } } + }, + "Sort Connections By" : { + }, "Sort Descending" : { "localizations" : { @@ -143980,6 +144551,9 @@ }, "Source column on the left, destination on the right." : { + }, + "Source filter" : { + }, "Source Unavailable" : { "localizations" : { @@ -144014,6 +144588,9 @@ } } } + }, + "source value, differs" : { + }, "Source:" : { "extractionState" : "stale", @@ -145061,7 +145638,10 @@ } } }, - "SqlPackage has no other way to receive one, so while the dump runs the password is readable by other processes on this Mac. Windows or Entra authentication avoids it." : { + "SqlPackage has no other way to receive one, so while it runs the password is readable by other processes on this Mac. Windows or Entra authentication avoids it." : { + + }, + "SRID %d is a projected coordinate system. Maps places longitude and latitude only, so these shapes cannot be drawn. Query ST_Transform(geom, 4326) to see them." : { }, "SSH" : { @@ -145794,6 +146374,9 @@ } } } + }, + "SSH Servers" : { + }, "SSH Tunnel" : { "localizations" : { @@ -147080,6 +147663,9 @@ }, "Statements already executed stay committed." : { + }, + "Statements run on the SSH server, so reads and writes act on the live database. The server needs python3." : { + }, "Statements TablePro generated and ran" : { "localizations" : { @@ -147814,6 +148400,9 @@ } } } + }, + "Stop allowing explicit identity values in %@" : { + }, "Stop and Close" : { "localizations" : { @@ -151114,6 +151703,9 @@ } } } + }, + "System Databases and Schemas" : { + }, "System Memory" : { @@ -152902,6 +153494,9 @@ } } } + }, + "Table Type" : { + }, "table_name" : { "localizations" : { @@ -154598,6 +155193,9 @@ } } } + }, + "Target filter" : { + }, "Target table for SQL output when exporting a query" : { "localizations" : { @@ -154632,6 +155230,9 @@ } } } + }, + "target value, differs" : { + }, "Target: %@" : { "localizations" : { @@ -155918,6 +156519,9 @@ }, "The %@ plugin could not be loaded." : { + }, + "The %@ plugin is not installed. TablePro offers to install it on connect." : { + }, "The %@ plugin is not installed. Would you like to download it from the plugin marketplace?" : { "localizations" : { @@ -156211,6 +156815,15 @@ } } } + }, + "The AWS credentials for this profile have expired. Sign in again and retry." : { + + }, + "The AWS RDS API in %@ returned a response TablePro could not read." : { + + }, + "The AWS region %@ is not enabled for this account." : { + }, "The bundled sample database is missing from the app." : { "localizations" : { @@ -156282,6 +156895,9 @@ }, "The change could not be applied: %@" : { + }, + "The change could not be saved. Check disk space and permissions, then try again." : { + }, "The ChatGPT account could not be identified." : { "localizations" : { @@ -157817,6 +158433,9 @@ } } } + }, + "The Keychain could not be read, so the stored secrets are not shown. Editing the name is safe; they are left as they are." : { + }, "The license has been suspended." : { "localizations" : { @@ -158483,6 +159102,9 @@ } } } + }, + "The output of a command" : { + }, "The pairing challenge is malformed." : { "localizations" : { @@ -159314,6 +159936,12 @@ } } } + }, + "The run was rolled back, but %@ cannot roll back, so the rows already written there stay. Compare again to see where the target stands." : { + + }, + "The run was rolled back, but %1$@ cannot roll back, so rows already written there stay in %2$@. Compare again to see where it stands." : { + }, "The run was rolled back. %@ is unchanged." : { "localizations" : { @@ -159927,6 +160555,12 @@ }, "The source and the target share no writable column." : { + }, + "The source could not be read with its filter: %@" : { + + }, + "The source could not be read: %@" : { + }, "The source driver cannot be read." : { @@ -160572,6 +161206,12 @@ } } } + }, + "The target could not be read with its filter: %@" : { + + }, + "The target could not be read: %@" : { + }, "The target database may be in a partial state. Review it and clean up as needed." : { @@ -161039,6 +161679,9 @@ }, "The tunnel command stopped. Click to reconnect." : { + }, + "The type's shape, such as enum, composite, domain, range, aliasType, tableType or clrType" : { + }, "The user cancelled this operation." : { "localizations" : { @@ -161809,6 +162452,12 @@ } } } + }, + "This column carries no SRID and its coordinates are outside the range of longitude and latitude, so the map cannot place them." : { + + }, + "This column holds %@, which the map cannot draw." : { + }, "This column needs a name." : { @@ -161965,6 +162614,9 @@ } } } + }, + "This connection uses a credential profile that isn't on this Mac." : { + }, "This connection was deleted on another device or window. Your changes were not saved." : { "localizations" : { @@ -162392,6 +163044,9 @@ }, "This database type has no dump TablePro can drive." : { + }, + "This database type signs in with the username and password above." : { + }, "This DELETE query has no WHERE clause and will delete ALL rows in the table. This action cannot be undone." : { "extractionState" : "stale", @@ -164485,6 +165140,9 @@ } } } + }, + "This plugin needs a newer version of TablePro. Update TablePro, then install it again." : { + }, "This plugin requires TablePro %@ or later" : { "localizations" : { @@ -164519,6 +165177,9 @@ } } } + }, + "This profile is not allowed to run rds:DescribeDBInstances in %@. Attach AmazonRDSReadOnlyAccess or an equivalent policy." : { + }, "This profile will be permanently deleted." : { "localizations" : { @@ -165087,6 +165748,9 @@ } } } + }, + "This statement changed %1$d rows where at most %2$d was expected." : { + }, "This statement drops or truncates data." : { @@ -170081,6 +170745,9 @@ } } } + }, + "Undo Edit Cell" : { + }, "Undo Staged Drop" : { "localizations" : { @@ -171723,6 +172390,9 @@ } } } + }, + "Unused" : { + }, "Update" : { "localizations" : { @@ -171825,6 +172495,9 @@ } } } + }, + "Update Available…" : { + }, "Update Now" : { "localizations" : { @@ -173136,6 +173809,9 @@ } } } + }, + "Use the same filter for the target" : { + }, "Use your Cursor subscription with no API key. Requires the Cursor CLI." : { "localizations" : { @@ -173204,6 +173880,9 @@ } } } + }, + "Used by %lld" : { + }, "User" : { "localizations" : { @@ -173650,6 +174329,9 @@ } } } + }, + "Uses the access key stored in ~/.aws/credentials." : { + }, "Uses this Mac's Application Default Credentials. Run `gcloud auth application-default login` first, or set GOOGLE_APPLICATION_CREDENTIALS." : { "localizations" : { @@ -174588,6 +175270,9 @@ } } } + }, + "Variable" : { + }, "Variant id" : { "localizations" : { @@ -175384,6 +176069,9 @@ } } } + }, + "View Full Changelog" : { + }, "View License" : { "localizations" : { @@ -175697,6 +176385,9 @@ } } } + }, + "Vim mode: %@" : { + }, "Waiting for %@" : { @@ -176472,6 +177163,9 @@ }, "What's New" : { + }, + "What's New in %@" : { + }, "When enabled, clicking a new table replaces the current clean table tab instead of opening a new tab" : { "extractionState" : "stale", diff --git a/TablePro/Views/Connection/TrailingPaneUnavailableView.swift b/TablePro/Views/Connection/TrailingPaneUnavailableView.swift index e14e3b016f..2da5e3846a 100644 --- a/TablePro/Views/Connection/TrailingPaneUnavailableView.swift +++ b/TablePro/Views/Connection/TrailingPaneUnavailableView.swift @@ -33,6 +33,8 @@ internal struct TrailingPaneUnavailableView: View { return String(localized: "Row fields appear once the connection is up") case .assistant: return String(localized: "The assistant answers once the connection is up") + case .history: + return String(localized: "Past queries appear once the connection is up") } } } diff --git a/TablePro/Views/Editor/History/HistoryPanelView.swift b/TablePro/Views/Editor/History/HistoryPanelView.swift index afeeeb15f1..e3145a586b 100644 --- a/TablePro/Views/Editor/History/HistoryPanelView.swift +++ b/TablePro/Views/Editor/History/HistoryPanelView.swift @@ -53,11 +53,24 @@ struct HistoryPanelView: View { } } + /// List above, detail below. + /// + /// It was list beside detail, which needed 260 + 280 of width and so could only ever live in a + /// band across the bottom of the window. The trailing pane it moved into is one shared + /// `NSSplitViewItem` with a 270pt floor, and raising that floor was measured and rejected: it + /// force-grows the pane past the width the reader chose and takes the difference from the + /// content. Rotating the split is what makes the same two panes fit, and it costs nothing, + /// because a query is a tall thing to read and a list of them is a narrow one. + /// + /// The autosave name moves with the orientation. The stored value is a divider offset along the + /// axis, so restoring a horizontal position into a vertical split puts the divider somewhere + /// the reader never put it. private func panel(_ viewModel: HistoryPanelViewModel) -> some View { AutosavingSplitView( - autosaveName: "com.TablePro.queryHistory.listDetail", - primaryMinimum: 260, - secondaryMinimum: 280, + autosaveName: "com.TablePro.queryHistory.listOverDetail", + isVertical: false, + primaryMinimum: 150, + secondaryMinimum: 180, collapsesPrimaryWhenTight: false ) { HistoryListPane( diff --git a/TablePro/Views/Editor/QueryEditorBar.swift b/TablePro/Views/Editor/QueryEditorBar.swift new file mode 100644 index 0000000000..188715da66 --- /dev/null +++ b/TablePro/Views/Editor/QueryEditorBar.swift @@ -0,0 +1,156 @@ +// +// QueryEditorBar.swift +// TablePro +// + +import SwiftUI +import TableProPluginKit + +/// The query tab's own command bar: what the editor runs against, and what it can do to the query. +/// +/// It lives in the tab rather than in the window toolbar, and that is a constraint rather than a +/// preference. `NSToolbar` belongs to the window, and a window here hosts several tabs of several +/// kinds. AppKit offers no way to vary a toolbar's items per tab without rewriting its item list, +/// and `NSToolbar.itemIdentifiers` says so in the header: it "will override any customizations the +/// user has made" when `allowsUserCustomization` is on, which this app's toolbar has. It is also +/// macOS 15, above the 14.0 deployment target. Putting Run in the toolbar therefore meant five +/// permanently dimmed items on every table, structure, dashboard and diagram tab. A control that +/// belongs to the editor lives with the editor, which is what Xcode's jump bar and Script Editor's +/// navigation bar both do. +/// +/// What it is not is the bar this replaced. That one opened with `Text("Query").font(.headline)`, +/// naming the pane it was already inside, and then mixed three control sizes and two button styles +/// across five controls with no grouping: borderless icon buttons for Clear, Format and Favorite, a +/// `.bordered` `.small` Explain, and a `.borderedProminent` `.small` Execute. Here every control is +/// one size, the scope leads, the commands trail, and the primary action is the only prominent one. +struct QueryEditorBar: View { + let scope: QueryScopeBarModel + let commands: QueryCommandAvailability + let isExecuting: Bool + let vimMode: VimMode? + + let onRun: () -> Void + let onRunAllStatements: () -> Void + let onRunWithoutLimit: () -> Void + let onStop: () -> Void + let onExplain: (ExplainVariant?) -> Void + let onFormat: () -> Void + let onSaveAsFavorite: () -> Void + let onClearQuery: () -> Void + let onClearResults: () -> Void + let onContainerChanged: (String) -> Void + + var body: some View { + HStack(spacing: 8) { + QueryContainerPicker( + containers: scope.containers, + selectedName: scope.selectedName, + entityName: scope.entityName, + isReadOnly: scope.isReadOnly, + schemaName: scope.schemaName, + onChange: onContainerChanged + ) + + if let vimMode { + VimModeIndicatorView(mode: vimMode) + } + + Spacer(minLength: 8) + + editingCommands + + explainControl + + runControl + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.bar) + .accessibilityIdentifier("query-editor-bar") + } + + /// The two that change the query without running it, in one `ControlGroup` because adjacent + /// related commands read as one control rather than as scattered singletons. + private var editingCommands: some View { + ControlGroup { + Button(String(localized: "Format"), systemImage: "text.alignleft", action: onFormat) + .disabled(!commands.canFormat) + .help(commands.formatHint) + + Button(String(localized: "Save as Favorite"), systemImage: "star", action: onSaveAsFavorite) + .disabled(!commands.canSaveAsFavorite) + .help(commands.favoriteHint) + } + .labelStyle(.iconOnly) + .controlSize(.small) + .fixedSize() + } + + /// A plain button when the engine has one plan to offer, and a pull-down when it has several. + /// Both are `.bordered` `.small`, which is what every other command here is. + @ViewBuilder + private var explainControl: some View { + if commands.explainVariants.count <= 1 { + Button(String(localized: "Explain")) { + onExplain(commands.explainVariants.first) + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(!commands.canExplain) + .help(commands.explainHint) + .accessibilityIdentifier("query-explain") + } else { + Menu(String(localized: "Explain")) { + ForEach(commands.explainVariants) { variant in + Button(variant.label) { onExplain(variant) } + } + } + .menuStyle(.button) + .buttonStyle(.bordered) + .controlSize(.small) + .fixedSize() + .disabled(!commands.canExplain) + .help(commands.explainHint) + .accessibilityIdentifier("query-explain") + } + } + + /// Run while idle, Stop while a query is in flight. + /// + /// One control rather than two side by side, because the bar is inside the pane and a second + /// permanently dimmed button costs width the editor wants. TablePlus does the same: its Cancel + /// appears in the query editor for a long query rather than standing there dimmed. The two are + /// never both actionable, so nothing is reachable in one state and not the other. + @ViewBuilder + private var runControl: some View { + if isExecuting { + Button(String(localized: "Stop"), systemImage: "stop.fill", action: onStop) + .buttonStyle(.bordered) + .controlSize(.small) + .labelStyle(.titleAndIcon) + .help(commands.stopHint) + .accessibilityIdentifier("query-stop") + } else { + Menu { + Button(String(localized: "Run All Statements"), action: onRunAllStatements) + Button(String(localized: "Run Without Limit"), action: onRunWithoutLimit) + Divider() + Button(String(localized: "Clear Query"), action: onClearQuery) + .disabled(!commands.canClearQuery) + Button(String(localized: "Clear Results"), action: onClearResults) + .disabled(!commands.canClearResults) + } label: { + Label(String(localized: "Run"), systemImage: "play.fill") + } primaryAction: { + onRun() + } + .menuStyle(.button) + .buttonStyle(.borderedProminent) + .controlSize(.small) + .fixedSize() + .disabled(!commands.canRun) + .help(commands.runHint) + .accessibilityIdentifier("query-run") + } + } +} diff --git a/TablePro/Views/Editor/QueryEditorView.swift b/TablePro/Views/Editor/QueryEditorView.swift index 24a9c0b096..b2945cebe4 100644 --- a/TablePro/Views/Editor/QueryEditorView.swift +++ b/TablePro/Views/Editor/QueryEditorView.swift @@ -2,23 +2,17 @@ // QueryEditorView.swift // TablePro // -// SQL query editor wrapper with toolbar -// -import os import SwiftUI import TableProEditorKit import TableProPluginKit -/// SQL query editor view with execute button +/// The SQL editor, its command bar, and the banners that belong to the document it holds. struct QueryEditorView: View { @Binding var queryText: String @Binding var cursorPositions: [CursorPosition] @Binding var parameters: [QueryParameter] @Binding var isParameterPanelVisible: Bool - var onExecute: () -> Void - var onExecuteWithoutLimit: (() -> Void)? - var onExecuteAllStatements: (() -> Void)? var schemaProvider: SQLSchemaProvider? var databaseType: DatabaseType? var databaseScope: DatabaseScope? @@ -36,28 +30,43 @@ struct QueryEditorView: View { var onExecuteQuery: (() -> Void)? var onRunStatement: ((String, Int) -> Bool)? var isExecuting: Bool = false - var showsHistoryTip: Bool = false - var onExplain: ((ExplainVariant?) -> Void)? var onAIExplain: ((String) -> Void)? var onAIOptimize: ((String) -> Void)? var onSaveAsFavorite: ((String) -> Void)? - var onClearResults: (() -> Void)? - var availableContainers: [DatabaseMetadata] = [] - var selectedContainerName: String = "" - var containerEntityName: String = "" - var isContainerSwitchReadOnly: Bool = false - var containerSchemaName: String? - var onContainerChanged: ((String) -> Void)? + + let scope: QueryScopeBarModel + let commands: QueryCommandAvailability + var onRun: () -> Void + var onRunAllStatements: () -> Void + var onRunWithoutLimit: () -> Void + var onStop: () -> Void + var onExplain: (ExplainVariant?) -> Void + var onFormat: () -> Void + var onSaveAsFavoriteCommand: () -> Void + var onClearQuery: () -> Void + var onClearResults: () -> Void + var onContainerChanged: (String) -> Void @State private var vimMode: VimMode = .normal var body: some View { - let hasQuery = !queryText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - VStack(alignment: .leading, spacing: 0) { - // Editor header with toolbar (above editor, higher z-index) - editorToolbar(hasQueryText: hasQuery) - .zIndex(1) + QueryEditorBar( + scope: scope, + commands: commands, + isExecuting: isExecuting, + vimMode: AppSettingsManager.shared.editor.vimModeEnabled ? vimMode : nil, + onRun: onRun, + onRunAllStatements: onRunAllStatements, + onRunWithoutLimit: onRunWithoutLimit, + onStop: onStop, + onExplain: onExplain, + onFormat: onFormat, + onSaveAsFavorite: onSaveAsFavoriteCommand, + onClearQuery: onClearQuery, + onClearResults: onClearResults, + onContainerChanged: onContainerChanged + ) Divider() @@ -99,157 +108,4 @@ struct QueryEditorView: View { } .background(Color(nsColor: .textBackgroundColor)) } - - // MARK: - Toolbar - - private func editorToolbar(hasQueryText: Bool) -> some View { - HStack { - Text("Query") - .font(.headline) - .foregroundStyle(.secondary) - - if AppSettingsManager.shared.editor.vimModeEnabled { - VimModeIndicatorView(mode: vimMode) - } - - QueryContainerPicker( - containers: availableContainers, - selectedName: selectedContainerName, - entityName: containerEntityName, - isReadOnly: isContainerSwitchReadOnly, - schemaName: containerSchemaName, - onChange: { name in onContainerChanged?(name) } - ) - - Spacer() - - Button(action: { - queryText = "" - onClearResults?() - }) { - Image(systemName: "trash") - .frame(width: 24, height: 24) - } - .buttonStyle(.borderless) - .help(String(localized: "Clear Query")) - .accessibilityLabel(String(localized: "Clear Query")) - - Button(action: formatQuery) { - Image(systemName: "text.alignleft") - .frame(width: 24, height: 24) - } - .buttonStyle(.borderless) - .help(shortcutHint(String(localized: "Format Query"), for: .formatQuery)) - .accessibilityLabel(String(localized: "Format Query")) - .optionalKeyboardShortcut(AppSettingsManager.shared.keyboard.keyboardShortcut(for: .formatQuery)) - - Button(action: { onSaveAsFavorite?(queryText) }) { - Image(systemName: "star") - .frame(width: 24, height: 24) - } - .buttonStyle(.borderless) - .help(shortcutHint(String(localized: "Save as Favorite"), for: .saveAsFavorite)) - .accessibilityLabel(String(localized: "Save as Favorite")) - .disabled(!hasQueryText) - - Divider() - .frame(height: 16) - - explainButton(hasQueryText: hasQueryText) - - Menu { - Button(String(localized: "Execute All Statements")) { - onExecuteAllStatements?() - } - .optionalKeyboardShortcut( - AppSettingsManager.shared.keyboard.keyboardShortcut(for: .executeAllStatements) - ) - - Button(String(localized: "Execute Without Limit")) { - onExecuteWithoutLimit?() - } - .optionalKeyboardShortcut( - AppSettingsManager.shared.keyboard.keyboardShortcut(for: .executeQueryWithoutLimit) - ) - } label: { - HStack(spacing: 4) { - Image(systemName: "play.fill") - Text("Execute") - } - } primaryAction: { - onExecute() - } - .menuStyle(.button) - .buttonStyle(.borderedProminent) - .controlSize(.small) - .fixedSize() - .help(shortcutHint(String(localized: "Execute"), for: .executeQuery)) - .optionalKeyboardShortcut(AppSettingsManager.shared.keyboard.keyboardShortcut(for: .executeQuery)) - .accessibilityIdentifier("query-execute-menu") - .modifier(FeatureTipPopoverAnchor( - tip: FindPastQueriesTip(shortcut: FeatureTipShortcut.display(for: .toggleHistory)), - isEnabled: showsHistoryTip - )) - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(Color(nsColor: .windowBackgroundColor)) - } - - // MARK: - Helpers - - private func shortcutHint(_ label: String, for action: ShortcutAction) -> String { - AppSettingsManager.shared.keyboard.shortcutHint(label, for: action) - } - - @ViewBuilder - private func explainButton(hasQueryText: Bool) -> some View { - let variants = databaseType?.explainVariants ?? [] - - if variants.count <= 1 { - Button { - onExplain?(variants.first) - } label: { - HStack(spacing: 4) { - Image(systemName: "chart.bar.doc.horizontal") - Text("Explain") - } - } - .buttonStyle(.bordered) - .controlSize(.small) - .help(shortcutHint(String(localized: "Explain"), for: .explainQuery)) - .disabled(!hasQueryText) - } else { - Menu { - ForEach(variants) { variant in - Button(variant.label) { onExplain?(variant) } - } - } label: { - HStack(spacing: 4) { - Image(systemName: "chart.bar.doc.horizontal") - Text("Explain") - } - } - .menuStyle(.borderlessButton) - .fixedSize() - .help(shortcutHint(String(localized: "Explain"), for: .explainQuery)) - .disabled(!hasQueryText) - } - } - - private func formatQuery() { - EditorEventRouter.shared.performFormatSQLForKeyWindow() - } -} - -#Preview { - QueryEditorView( - queryText: .constant("SELECT * FROM users\nWHERE active = true\nORDER BY created_at DESC;"), - cursorPositions: .constant([]), - parameters: .constant([]), - isParameterPanelVisible: .constant(false), - onExecute: {}, - databaseType: .mysql - ) - .frame(width: 600, height: 200) } diff --git a/TablePro/Views/Editor/VimModeIndicatorView.swift b/TablePro/Views/Editor/VimModeIndicatorView.swift index 46daad881d..a021c4c777 100644 --- a/TablePro/Views/Editor/VimModeIndicatorView.swift +++ b/TablePro/Views/Editor/VimModeIndicatorView.swift @@ -12,6 +12,13 @@ struct VimModeIndicatorView: View { let mode: VimMode var body: some View { + badge + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityDescription) + } + + @ViewBuilder + private var badge: some View { if case .commandLine = mode { Text(mode.displayLabel) .font(.system(.caption, design: .monospaced)) @@ -31,6 +38,13 @@ struct VimModeIndicatorView: View { } } + /// The label alone reads as a bare word in the middle of the status bar. Naming what the word + /// is makes it a sentence. Colour is never the only difference between two modes here: the + /// label always says which mode it is, so `differentiateWithoutColor` needs nothing extra. + private var accessibilityDescription: String { + String(format: String(localized: "Vim mode: %@"), mode.displayLabel) + } + private var foregroundColor: Color { switch mode { case .normal: return .secondary diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 5fe0d48975..752ab50f4c 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -21,11 +21,6 @@ private struct TabLoadKey: Hashable { } struct MainEditorContentView: View { - /// A query tab nests its own editor/results split, whose two minimums are required constraints. - /// The drawer's own minimum has to clear their sum, or dragging the drawer down asks AppKit to - /// satisfy a height the content it contains cannot reach. - static let tabContentMinimumHeight = VerticalCollapsibleSplitView.combinedMinimumThickness - // MARK: - Dependencies var tabManager: QueryTabManager @@ -82,6 +77,8 @@ struct MainEditorContentView: View { return AnyChangeManager(changeManager) } + /// The tip that tells a first-time reader where past queries are. It is answered once history + /// has been on screen, which is now the trailing pane rather than a drawer under the editor. private var showsHistoryTip: Bool { let historyState = HistoryPanelState.forConnection(connectionId) return !historyState.isVisible && !historyState.isCapturePaused @@ -92,27 +89,13 @@ struct MainEditorContentView: View { var body: some View { @Bindable var historyState = HistoryPanelState.forConnection(connectionId) - VerticalCollapsibleSplitView( - isBottomCollapsed: Binding( - get: { !historyState.isVisible }, - set: { historyState.isVisible = !$0 } - ), - autosaveName: SplitViewAutosaveName.historyDrawer(connectionId: connectionId), - topMinimumThickness: Self.tabContentMinimumHeight, - bottomMinimumThickness: 180, - topContent: { - // Native macOS window tabs replace the custom tab bar. - // Each window-tab contains a single tab, so no ZStack keep-alive is needed. - if let tab = tabManager.selectedTab { - tabContent(for: tab) - } else { - emptyStateView - } - }, - bottomContent: { - HistoryPanelView(coordinator: coordinator) + return Group { + if let tab = tabManager.selectedTab { + tabContent(for: tab) + } else { + emptyStateView } - ) + } .background(.background) .onChange(of: historyState.isVisible, initial: true) { _, isVisible in if isVisible { @@ -437,9 +420,6 @@ struct MainEditorContentView: View { cursorPositions: $bindableCoordinator.cursorPositions, parameters: parameterBinding(for: tab), isParameterPanelVisible: parameterVisibilityBinding(for: tab), - onExecute: { coordinator.runQuery(viewport: .firstRow) }, - onExecuteWithoutLimit: { coordinator.runQuery(viewport: .firstRow, bypassRowLimit: true) }, - onExecuteAllStatements: { coordinator.runAllStatements() }, schemaProvider: queryScope.map { SchemaProviderRegistry.shared.getOrCreate(for: $0) }, databaseType: coordinator.connection.type, databaseScope: queryScope, @@ -465,8 +445,6 @@ struct MainEditorContentView: View { onExecuteQuery: { coordinator.runQuery(viewport: .firstRow) }, onRunStatement: { sql, offset in coordinator.runStatement(sql, sourceOffset: offset) }, isExecuting: coordinator.tabExecution.isExecuting(tab.id), - showsHistoryTip: showsHistoryTip, - onExplain: { variant in coordinator.runExplain(variant: variant) }, onAIExplain: { text in coordinator.showAssistant() coordinator.aiViewModel?.handleExplainSelection(text) @@ -479,12 +457,17 @@ struct MainEditorContentView: View { guard !text.isEmpty else { return } coordinator.favoriteDialogQuery = FavoriteDialogQuery(query: text) }, + scope: scopeBarModel(for: tab), + commands: commandAvailability(for: tab), + onRun: { coordinator.runQuery(viewport: .firstRow) }, + onRunAllStatements: { coordinator.runAllStatements() }, + onRunWithoutLimit: { coordinator.runQuery(viewport: .firstRow, bypassRowLimit: true) }, + onStop: { coordinator.cancelCurrentQuery() }, + onExplain: { variant in coordinator.runExplain(variant: variant) }, + onFormat: { EditorEventRouter.shared.performFormatSQLForKeyWindow() }, + onSaveAsFavoriteCommand: { coordinator.saveCurrentQueryAsFavorite() }, + onClearQuery: { coordinator.commandActions?.clearQuery() }, onClearResults: { coordinator.clearActiveQueryResults() }, - availableContainers: containerDatabases(for: tab), - selectedContainerName: containerName(for: tab), - containerEntityName: containerEntityName, - isContainerSwitchReadOnly: isContainerSwitchReadOnly, - containerSchemaName: containerSchemaName(for: tab), onContainerChanged: { name in changeContainer(for: tab, to: name) } ) } @@ -526,13 +509,11 @@ struct MainEditorContentView: View { coordinator.tabManager.mutate(tabId: tabId) { $0.content.externalModificationDetected = false } } + /// Both facts the toolbar's query items validate against, written together. They used to be one + /// fact, because the only thing that read it was a button inside this view which already knew + /// it was on a query tab. The toolbar does not: it belongs to the window and outlives every tab. private func updateHasQueryText() { - if let tab = tabManager.selectedTab, tab.tabType == .query { - coordinator.toolbarState.hasQueryText = !tab.content.query.trimmingCharacters(in: .whitespacesAndNewlines) - .isEmpty - } else { - coordinator.toolbarState.hasQueryText = false - } + coordinator.syncQueryToolbarStateForSelectedTab() } private func queryTextBinding(for tab: QueryTab) -> Binding { @@ -689,150 +670,173 @@ struct MainEditorContentView: View { .frame(maxHeight: .infinity) } + /// Renders `QueryResultPresentation`. Every branch this used to take lived here as a switch + /// over the view mode whose arms each repeated the result chrome, wrapped around a nested + /// `if/else` chain. The decision is a pure value now, so this is a rendering and nothing else. @ViewBuilder private func resultsSection(tab: QueryTab) -> some View { + let rows = resolvedTableRows(for: tab) + let presentation = resultPresentation(for: tab, rows: rows) + VStack(spacing: 0) { - executionErrorBanner(tab: tab) - switch tab.display.resultsViewMode { - case .structure: - if let tableName = tab.tableContext.tableName { - structureContent(tab: tab, tableName: tableName) - } - case .json: - resultTabBarSection(tab: tab) - rowFilterChrome(tab: tab, rows: resolvedTableRows(for: tab)) - ResultsJsonView( - tableRows: resolvedTableRows(for: tab), - selectedRowIndices: selectionState.indices, - displayIDs: coordinator.displayIDs(forTab: tab.id), - deletedRowIDs: changeManager.deletedRowIDs, - valueFilter: tab.valueFilter, - dataRevision: coordinator.tabSessionRegistry.session(for: tab.id)?.dataRevision ?? 0, - displayRevision: coordinator.gridDisplayRevision, - columnLayout: tab.columnLayout + if presentation.showsErrorBanner { + executionErrorBanner(tab: tab) + } + + if presentation.showsFilterChrome { + rowFilterChrome(tab: tab, rows: rows) + } + + if presentation.showsFindBar { + FindBarView( + coordinator: coordinator, + findState: tab.findState, + rowsRevision: tab.loadEpoch + &+ tab.pagination.currentPage + &+ tab.paginationVersion + &+ rows.rows.count, + onSearchAllRows: { coordinator.findCoordinator.escalateToAllRows() } ) + /// Per tab, like the grid below it. The field text lives in the view's own + /// `@State`, seeded once from `onAppear`, and the grid's find tint lives on a + /// coordinator that `.id(tabId)` rebuilds from nothing. With find open on both tabs + /// this view kept its identity across a switch, so neither was re-seeded: the field + /// showed the other tab's term next to this tab's match count, and the grid came + /// back untinted. (#2667) .id(tab.id) - case .chart: - resultTabBarSection(tab: tab) - if let explain = tab.display.activeExplainResult { - queryPlanResultView(for: explain, in: tab) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let resultSet = tab.display.activeResultSet { - ResultChartView( - configuration: chartConfigurationBinding(for: tab), - tableRows: resolvedTableRows(for: tab), - primaryKeyColumns: Set(tab.tableContext.primaryKeyColumns), - tabId: tab.id, - resultSetId: resultSet.id, - dataRevision: coordinator.tabSessionRegistry.session(for: tab.id)?.dataRevision ?? 0, - isUnlocked: LicenseManager.shared.isFeatureAvailable(.resultCharts) - ) - } else { - ContentUnavailableView( - String(localized: "No Data"), - systemImage: "chart.bar.xaxis", - description: Text(String(localized: "Execute a query to chart its loaded rows.")) - ) - } - case .map: - resultTabBarSection(tab: tab) - if let resultSet = tab.display.activeResultSet { - ResultMapView( - configuration: mapConfigurationBinding(for: tab), - columns: tab.display.spatialColumns, - tableRows: resolvedTableRows(for: tab), - displayIDs: coordinator.displayIDs(forTab: tab.id), - selectedRowIndices: selectionState.indices, - tabId: tab.id, - resultSetId: resultSet.id, - dataRevision: coordinator.tabSessionRegistry.session(for: tab.id)?.dataRevision ?? 0, - displayRevision: coordinator.gridDisplayRevision, - onSelectRow: { displayIndex in - let rows: Set = displayIndex.map { [$0] } ?? [] - selectionState.indices = rows - /// The shared channel alone does not survive the trip to Data mode: the - /// grid remounts and restores the tab's own stored selection over it, - /// which a map click never wrote. Storing it here is the same half that - /// #2667 added for a mode switch, and the cell rectangle is cleared - /// because a shape names a row and no columns. - coordinator.storeGridSelection(rows: rows, cells: .empty, forTab: tab.id) - } - ) - .id(tab.id) - } else { - ContentUnavailableView( - String(localized: "No Data"), - systemImage: "map", - description: Text(String(localized: "Execute a query to map its loaded rows.")) - ) - } - case .data: - resultTabBarSection(tab: tab) - if let explain = tab.display.activeExplainResult { - queryPlanResultView(for: explain, in: tab) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - let resolvedRows = resolvedTableRows(for: tab) - if let rs = tab.display.activeResultSet, rs.resultColumns.isEmpty, - rs.errorMessage == nil, tab.execution.lastExecutedAt != nil, - !coordinator.tabExecution.isExecuting(tab.id) - { - ResultSuccessView( - rowsAffected: rs.rowsAffected, - executionTime: rs.executionTime, - statusMessage: rs.statusMessage - ) - } else if resolvedRows.columns.isEmpty && tab.execution.errorMessage == nil - && tab.execution.lastExecutedAt != nil && !coordinator.tabExecution.isExecuting(tab.id) - { - if tab.display.resultSets.isEmpty { - Spacer() - } else { - ResultSuccessView( - rowsAffected: tab.execution.rowsAffected, - executionTime: tab.execution.executionTime, - statusMessage: tab.execution.statusMessage - ) - } - } else { - rowFilterChrome(tab: tab, rows: resolvedRows) - - if tab.findState.isVisible && tab.tabType == .table { - FindBarView( - coordinator: coordinator, - findState: tab.findState, - rowsRevision: tab.loadEpoch - &+ tab.pagination.currentPage - &+ tab.paginationVersion - &+ resolvedRows.rows.count, - onSearchAllRows: { coordinator.findCoordinator.escalateToAllRows() } - ) - /// Per tab, like the grid below it. The field text lives in the view's - /// own `@State`, seeded once from `onAppear`, and the grid's find tint - /// lives on a coordinator that `.id(tabId)` rebuilds from nothing. With - /// find open on both tabs this view kept its identity across a switch, - /// so neither was re-seeded: the field showed the other tab's term next - /// to this tab's match count, and the grid came back untinted. (#2667) - .id(tab.id) - Divider() - } - - if showsEmptyResultView(tab: tab, rows: resolvedRows) { - emptyResultView(executionTime: tab.display.activeResultSet?.executionTime ?? tab.execution.executionTime) - } else { - dataGridView(tab: tab) - } - } - } + Divider() } - if tab.display.activeExplainResult == nil { + resultContent(presentation.content, tab: tab, rows: rows) + + if presentation.showsStatusBar { statusBar(tab: tab) } } .frame(maxWidth: .infinity, maxHeight: .infinity) } + @ViewBuilder + private func resultContent( + _ content: QueryResultContent, + tab: QueryTab, + rows: TableRows + ) -> some View { + switch content { + case .idle: + Spacer() + case .executing: + Spacer() + case let .structure(tableName): + structureContent(tab: tab, tableName: tableName) + case .queryPlan: + if let explain = tab.display.activeExplainResult { + queryPlanResultView(for: explain, in: tab) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + case .chart: + if let resultSet = tab.display.activeResultSet { + ResultChartView( + configuration: chartConfigurationBinding(for: tab), + tableRows: rows, + primaryKeyColumns: Set(tab.tableContext.primaryKeyColumns), + tabId: tab.id, + resultSetId: resultSet.id, + dataRevision: coordinator.tabSessionRegistry.session(for: tab.id)?.dataRevision ?? 0, + isUnlocked: LicenseManager.shared.isFeatureAvailable(.resultCharts) + ) + } + case .map: + if let resultSet = tab.display.activeResultSet { + ResultMapView( + configuration: mapConfigurationBinding(for: tab), + columns: tab.display.spatialColumns, + tableRows: rows, + displayIDs: coordinator.displayIDs(forTab: tab.id), + selectedRowIndices: selectionState.indices, + tabId: tab.id, + resultSetId: resultSet.id, + dataRevision: coordinator.tabSessionRegistry.session(for: tab.id)?.dataRevision ?? 0, + displayRevision: coordinator.gridDisplayRevision, + onSelectRow: { displayIndex in + let selected: Set = displayIndex.map { [$0] } ?? [] + selectionState.indices = selected + /// The shared channel alone does not survive the trip to Data mode: the + /// grid remounts and restores the tab's own stored selection over it, + /// which a map click never wrote. Storing it here is the same half that + /// #2667 added for a mode switch, and the cell rectangle is cleared + /// because a shape names a row and no columns. + coordinator.storeGridSelection(rows: selected, cells: .empty, forTab: tab.id) + } + ) + .id(tab.id) + } + case .json: + ResultsJsonView( + tableRows: rows, + selectedRowIndices: selectionState.indices, + displayIDs: coordinator.displayIDs(forTab: tab.id), + deletedRowIDs: changeManager.deletedRowIDs, + valueFilter: tab.valueFilter, + dataRevision: coordinator.tabSessionRegistry.session(for: tab.id)?.dataRevision ?? 0, + displayRevision: coordinator.gridDisplayRevision, + columnLayout: tab.columnLayout + ) + .id(tab.id) + case .grid: + dataGridView(tab: tab) + case let .noRows(executionTime): + emptyResultView(executionTime: executionTime) + case let .statementSucceeded(rowsAffected, executionTime, statusMessage): + ResultSuccessView( + rowsAffected: rowsAffected, + executionTime: executionTime, + statusMessage: statusMessage + ) + case let .unavailable(mode): + unavailableModeView(mode) + } + } + + private func unavailableModeView(_ mode: ResultsViewMode) -> some View { + ContentUnavailableView( + String(localized: "No Data"), + systemImage: mode == .map ? "map" : "chart.bar.xaxis", + description: Text(mode == .map + ? String(localized: "Execute a query to map its loaded rows.") + : String(localized: "Execute a query to chart its loaded rows.")) + ) + } + + /// Gathers what the resolver needs. The one place tab state is read for this decision, so the + /// conditions cannot drift apart the way they did while each arm tested its own combination. + private func resultPresentation(for tab: QueryTab, rows: TableRows) -> QueryResultPresentation { + let activeResultSet = tab.display.activeResultSet + var inputs = QueryResultInputs() + inputs.tabType = tab.tabType + inputs.viewMode = tab.display.resultsViewMode + inputs.tableName = tab.tableContext.tableName + inputs.isExecuting = coordinator.tabExecution.isExecuting(tab.id) + inputs.hasExecuted = tab.execution.lastExecutedAt != nil + inputs.isExplainResult = tab.display.activeExplainResult != nil + inputs.hasActiveResultSet = activeResultSet != nil + inputs.resultSetCount = tab.display.resultSets.count + inputs.activeResultHasColumns = !(activeResultSet?.resultColumns.isEmpty ?? true) + inputs.activeResultRowsAffected = activeResultSet?.rowsAffected ?? 0 + inputs.activeResultExecutionTime = activeResultSet?.executionTime + inputs.activeResultStatusMessage = activeResultSet?.statusMessage + inputs.loadedColumnCount = rows.columns.count + inputs.loadedRowCount = rows.rows.count + inputs.executionErrorMessage = tab.execution.errorMessage + inputs.executionRowsAffected = tab.execution.rowsAffected + inputs.executionTime = tab.execution.executionTime + inputs.executionStatusMessage = tab.execution.statusMessage + inputs.hasAppliedFilters = tab.filterState.hasAppliedFilters + inputs.isFilterPanelVisible = tab.filterState.isVisible + inputs.isFindBarVisible = tab.findState.isVisible + return QueryResultPresentation(inputs: inputs) + } + /// Shared by every mode whose `showsRowFilters` is true. Filtering rebuilds the query and /// re-runs it, so a mode that renders this panel shows filtered rows without knowing about it. @ViewBuilder @@ -875,38 +879,11 @@ struct MainEditorContentView: View { .id(resultSet.id) } - @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 { - ResultTabBar( - resultSets: tab.display.resultSets, - activeResultSetId: Binding( - get: { tab.display.activeResultSetId }, - set: { newId in - coordinator.switchActiveResultSet(to: newId, in: tab.id) - } - ), - onClose: { id in - coordinator.closeResultSet(id: id) - }, - onTogglePin: { id in - coordinator.togglePinResultSet(id: id) - } - ) - } - - /// A query that came back with columns and no rows shows this instead of a grid, so anything - /// that offers a jump into the grid reads the same condition. - private func showsEmptyResultView(tab: QueryTab, rows: TableRows) -> Bool { - tab.tabType == .query && !rows.columns.isEmpty - && rows.rows.isEmpty && tab.execution.lastExecutedAt != nil - && !coordinator.tabExecution.isExecuting(tab.id) && !tab.filterState.hasAppliedFilters + /// Whether the grid is the thing on screen, which is what decides if there is anything to jump + /// a column into. Asked of the resolver so it cannot disagree with what was actually rendered, + /// which is what a second hand-written copy of the condition used to do. + private func showsGrid(tab: QueryTab, rows: TableRows) -> Bool { + resultPresentation(for: tab, rows: rows).content == .grid } private func emptyResultView(executionTime: TimeInterval?) -> some View { @@ -1053,6 +1030,7 @@ struct MainEditorContentView: View { displayRowCount: coordinator.displayIDs(forTab: tab.id)?.count, isFetching: isExecuting, hasStructureActions: structureFooter.isActive, + isQueryPlan: tab.display.activeExplainResult != nil, paginationCapability: coordinator.paginationCapability ) return ResultStatusBar( @@ -1070,8 +1048,8 @@ struct MainEditorContentView: View { onShowAll: { coordinator.showAllColumns() }, onHideAll: { coordinator.hideAllColumns($0) }, onReset: { coordinator.resetColumns() }, - onJumpToColumn: tab.display.resultsViewMode == .data && !tab.display.isResultsCollapsed - && !showsEmptyResultView(tab: tab, rows: resolvedRows) + onJumpToColumn: !tab.display.isResultsCollapsed + && showsGrid(tab: tab, rows: resolvedRows) ? { coordinator.showColumnJump(seededWith: $0) } : nil ), @@ -1106,6 +1084,15 @@ struct MainEditorContentView: View { ), isRefreshingSchema: SchemaService.shared.isRefreshing(connectionId: connectionId), viewMode: resultsViewModeBinding(for: tab), + resultSetMenu: resultSetMenuModel(for: tab), + onActivateResultSet: { coordinator.switchActiveResultSet(to: $0, in: tab.id) }, + onToggleResultSetPin: { coordinator.togglePinResultSet(id: $0) }, + onCloseResultSet: { coordinator.closeResultSet(id: $0) }, + onCloseOtherResultSets: { keptId in + for other in tab.display.resultSets where other.id != keptId && !other.isPinned { + coordinator.closeResultSet(id: other.id) + } + }, onToggleFilters: { coordinator.toggleFilterPanel() }, onFetchAll: { coordinator.fetchAllRows() }, onStructureAdd: { coordinator.structureActions?.addRow?() }, @@ -1113,6 +1100,60 @@ struct MainEditorContentView: View { ) } + /// Empty unless the resolver says the chooser is on screen, so the bar and the pane agree on + /// whether there is a choice to make. + /// + /// Asked with only the three fields that decide it rather than a full `resultPresentation`, + /// which would pull the tab's whole row buffer out of the session registry to answer a question + /// that does not depend on a single row. + private func resultSetMenuModel(for tab: QueryTab) -> ResultSetMenuModel { + var selectorInputs = QueryResultInputs() + selectorInputs.tabType = tab.tabType + selectorInputs.viewMode = tab.display.resultsViewMode + selectorInputs.resultSetCount = tab.display.resultSets.count + guard QueryResultPresentation(inputs: selectorInputs).showsResultSetSelector else { + return ResultSetMenuModel(entries: [], activeOrdinal: 0, total: 0) + } + let activeId = tab.display.activeResultSet?.id + let entries = tab.display.resultSets.enumerated().map { index, resultSet in + ResultSetMenuEntry( + id: resultSet.id, + label: resultSet.label, + isPinned: resultSet.isPinned, + isActive: resultSet.id == activeId, + ordinal: index + 1 + ) + } + return ResultSetMenuModel( + entries: entries, + activeOrdinal: entries.first(where: \.isActive)?.ordinal ?? entries.count, + total: entries.count + ) + } + + private func scopeBarModel(for tab: QueryTab) -> QueryScopeBarModel { + QueryScopeBarModel( + containers: containerDatabases(for: tab), + selectedName: containerName(for: tab), + entityName: containerEntityName, + isReadOnly: isContainerSwitchReadOnly, + schemaName: containerSchemaName(for: tab) + ) + } + + private func commandAvailability(for tab: QueryTab) -> QueryCommandAvailability { + QueryCommandAvailability( + isConnected: MainWindowToolbar.hasLiveSession(coordinator.toolbarState.connectionState), + hasQueryText: tab.hasQueryText, + isExecuting: coordinator.tabExecution.isExecuting(tab.id), + hasResults: coordinator.canClearActiveQueryResults, + explainVariants: coordinator.connection.type.explainVariants, + shortcutHint: { label, action in + AppSettingsManager.shared.keyboard.shortcutHint(label, for: action) + } + ) + } + private func resultsViewModeBinding(for tab: QueryTab) -> Binding { Binding( get: { tab.display.resultsViewMode }, diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift index 113a4a4df8..98c8da93b0 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift @@ -15,7 +15,7 @@ extension MainContentCoordinator { var canPinActiveResultSet: Bool { guard let tab = tabManager.selectedTab else { return false } - return ResultTabBarPolicy.canPin(tabType: tab.tabType, display: tab.display) + return ResultSetPolicy.canPin(tabType: tab.tabType, display: tab.display) } var isActiveResultSetPinned: Bool { @@ -62,7 +62,14 @@ extension MainContentCoordinator { if let lastPinned = tabManager.tabs[tabIdx].display.resultSets.last(where: \.isPinned) { applyResultSetSwitch(to: lastPinned.id, in: tabId) - tabManager.mutate(at: tabIdx) { $0.display.removeUnpinnedResults() } + tabManager.mutate(at: tabIdx) { tab in + tab.display.removeUnpinnedResults() + /// A failed execution records its message on the tab rather than on a result set, + /// so returning here without clearing it left the banner over the pinned result the + /// switch just revealed, and only the banner's own Dismiss could take it away. + tab.execution.errorMessage = nil + tab.execution.errorQuery = nil + } return } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift index 46150c5fd7..617f163c0c 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift @@ -103,6 +103,7 @@ extension MainContentCoordinator { selectionState.indices = newTab.selectedDisplayRows toolbarState.isTableTab = newTab.tabType == .table toolbarState.isResultsCollapsed = newTab.display.isResultsCollapsed + syncQueryToolbarState(for: newTab) let pendingState = newTab.pendingChanges if pendingState.hasChanges { @@ -141,9 +142,38 @@ extension MainContentCoordinator { } else { toolbarState.isTableTab = false toolbarState.isResultsCollapsed = false + toolbarState.isQueryTab = false + toolbarState.hasQueryText = false } } + /// What the toolbar's Run, Explain, Format and Favorite items validate against. + /// + /// Execution state is deliberately not among these: `TabExecutionRegistry` is the single answer + /// to "is something running", and its own documentation records that a hand-kept mirror of that + /// is what let the titlebar report a query which had already ended (#2342). The toolbar reads + /// it live through `isSelectedTabExecuting`. + func syncQueryToolbarState(for tab: QueryTab) { + toolbarState.isQueryTab = tab.tabType == .query + toolbarState.hasQueryText = tab.tabType == .query && tab.hasQueryText + } + + func syncQueryToolbarStateForSelectedTab() { + guard let tab = tabManager.selectedTab else { + toolbarState.isQueryTab = false + toolbarState.hasQueryText = false + return + } + syncQueryToolbarState(for: tab) + } + + /// Whether the tab the toolbar is pointed at has a query in flight, asked of the registry each + /// time rather than stored. + var isSelectedTabExecuting: Bool { + guard let tabId = tabManager.selectedTabId else { return false } + return tabExecution.isExecuting(tabId) + } + /// Whether dropping this tab's rows is safe, which is exactly whether `canAutoLoadTableTab` /// will bring them back. The two answers have to agree: a tab evicted without a route back to /// its rows shows an empty grid until the user refreshes it by hand. diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index fd06df12f5..e256c5eda3 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -1220,6 +1220,34 @@ final class MainContentCommandActions { EditorEventRouter.shared.performFormatSQLForKeyWindow() } + /// Emptying the editor and discarding the results are two commands, not one. + /// + /// They used to be a single trash button whose tooltip and accessibility label both said + /// "Clear Query" while it also cleared the results, the execution record and collapsed the + /// results pane. Neither half had a menu-bar command, so neither could be undone, reached by + /// keyboard, or announced for what it was. + func clearQuery() { + guard let coordinator, + let (tab, tabIndex) = coordinator.tabManager.selectedTabAndIndex, + tab.tabType == .query else { return } + coordinator.tabManager.mutate(at: tabIndex) { $0.content.query = "" } + coordinator.toolbarState.hasQueryText = false + coordinator.scheduleDraftSave() + } + + var canClearQuery: Bool { + guard let tab = coordinator?.tabManager.selectedTab, tab.tabType == .query else { return false } + return !tab.content.query.isEmpty + } + + func clearResults() { + coordinator?.clearActiveQueryResults() + } + + var canClearResults: Bool { + coordinator?.canClearActiveQueryResults ?? false + } + func removeInvisibleCharacters() { EditorEventRouter.shared.performRemoveInvisibleCharactersForKeyWindow() } @@ -1257,10 +1285,11 @@ final class MainContentCommandActions { // MARK: - UI Operations (Group A — Called Directly) + /// History is a trailing-pane surface, so the command that shows it is the same shape as the + /// two beside it. `HistoryPanelState.isVisible` is written by the pane rather than here, which + /// is what keeps the flag describing what the window shows instead of racing it. func toggleHistoryPanel() { - guard let connectionId = coordinator?.connectionId else { return } - let state = HistoryPanelState.forConnection(connectionId) - state.isVisible.toggle() + coordinator?.trailingPaneProxy?.toggleHistory() } func toggleRightSidebar() { diff --git a/TablePro/Views/Results/ResultSetMenu.swift b/TablePro/Views/Results/ResultSetMenu.swift new file mode 100644 index 0000000000..aad0428941 --- /dev/null +++ b/TablePro/Views/Results/ResultSetMenu.swift @@ -0,0 +1,78 @@ +// +// ResultSetMenu.swift +// TablePro +// + +import SwiftUI + +/// The control that chooses which result set the pane shows. +/// +/// A pull-down in the status bar's leading zone, beside the view-mode switcher, which is where +/// Postico 2 puts the same control and where Script Editor puts its Description/Result/Log +/// selector. It costs no band of its own, which is the point: the strip it replaces spent 32pt in +/// every query tab, including the overwhelmingly common one with a single result. +/// +/// Pin, Unpin, Close and Close Others move across unchanged from the strip's context menu. +struct ResultSetMenu: View { + let model: ResultSetMenuModel + /// Spelled out where the bar has room, counted in figures where it does not. + let isSpelledOut: Bool + let onActivate: (UUID) -> Void + let onTogglePin: (UUID) -> Void + let onClose: (UUID) -> Void + let onCloseOthers: (UUID) -> Void + + var body: some View { + Menu { + ForEach(model.entries) { entry in + Button { + onActivate(entry.id) + } label: { + /// A checkmark on the active one and a pin glyph on the pinned ones, which is + /// the menu vocabulary for "this is current" and "this is held". + Label { + Text(entry.label) + } icon: { + if entry.isActive { + Image(systemName: "checkmark") + } else if entry.isPinned { + Image(systemName: "pin.fill") + } + } + } + } + + if let active = model.activeEntry { + Divider() + + Button(active.isPinned + ? String(localized: "Unpin Result") + : String(localized: "Pin Result") + ) { + onTogglePin(active.id) + } + + Button(String(localized: "Close Result")) { onClose(active.id) } + .disabled(!model.canClose(active)) + + Button(String(localized: "Close Other Results")) { onCloseOthers(active.id) } + .disabled(!model.canCloseOthers(active)) + } + } label: { + HStack(spacing: 4) { + if model.activeEntry?.isPinned == true { + Image(systemName: "pin.fill") + .imageScale(.small) + } + Text(isSpelledOut ? model.title : model.compactTitle) + } + } + .menuStyle(.button) + .buttonStyle(.accessoryBar) + .controlSize(.small) + .fixedSize() + .help(String(localized: "Choose which result this pane shows")) + .accessibilityLabel(model.title) + .accessibilityIdentifier("result-set-menu") + } +} diff --git a/TablePro/Views/Results/ResultStatusBar.swift b/TablePro/Views/Results/ResultStatusBar.swift index 56d25ed202..2935748a6e 100644 --- a/TablePro/Views/Results/ResultStatusBar.swift +++ b/TablePro/Views/Results/ResultStatusBar.swift @@ -43,6 +43,12 @@ struct ResultStatusBar: View { /// this window is. It had no surface at all between the centred toolbar item going and this. let isRefreshingSchema: Bool @Binding var viewMode: ResultsViewMode + /// The result-set chooser, absent when the tab holds at most one result. + let resultSetMenu: ResultSetMenuModel + let onActivateResultSet: (UUID) -> Void + let onToggleResultSetPin: (UUID) -> Void + let onCloseResultSet: (UUID) -> Void + let onCloseOtherResultSets: (UUID) -> Void let onToggleFilters: () -> Void let onFetchAll: (() -> Void)? let onStructureAdd: () -> Void @@ -81,6 +87,16 @@ struct ResultStatusBar: View { if model.controls.showsModeSwitcher { modeSwitcher(presentation) } + if !resultSetMenu.isEmpty { + ResultSetMenu( + model: resultSetMenu, + isSpelledOut: presentation.resultSetMenuIsSpelledOut, + onActivate: onActivateResultSet, + onTogglePin: onToggleResultSetPin, + onClose: onCloseResultSet, + onCloseOthers: onCloseOtherResultSets + ) + } if model.controls.showsReadout { readoutCluster .frame( diff --git a/TablePro/Views/Results/ResultTabBar.swift b/TablePro/Views/Results/ResultTabBar.swift deleted file mode 100644 index 04b9fb5439..0000000000 --- a/TablePro/Views/Results/ResultTabBar.swift +++ /dev/null @@ -1,202 +0,0 @@ -// -// ResultTabBar.swift -// TablePro -// -// 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 - -struct ResultTabBar: View { - let resultSets: [ResultSet] - @Binding var activeResultSetId: UUID? - var onClose: ((UUID) -> Void)? - var onTogglePin: ((UUID) -> Void)? - - var body: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 2) { - ForEach(resultSets) { rs in - resultTab(rs) - } - } - .padding(.horizontal, 4) - } - .frame(height: 32) - .background(.bar) - } - - private func resultTab(_ rs: ResultSet) -> some View { - ResultTab( - label: rs.label, - isPinned: rs.isPinned, - isActive: rs.id == (activeResultSetId ?? resultSets.last?.id), - onActivate: { activeResultSetId = rs.id }, - onTogglePin: { onTogglePin?(rs.id) }, - onClose: rs.isPinned ? nil : { onClose?(rs.id) }, - revealsStatement: rs.statementAnchor != nil - ) - .help(provenance(of: rs)) - .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) - } - } - } - - 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(RevealedText(errorMessage).plainText) - } - 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 { - let label: String - let isPinned: Bool - let isActive: Bool - let onActivate: () -> Void - let onTogglePin: () -> Void - let onClose: (() -> Void)? - /// Whether choosing this result also takes the reader to the statement that produced it, which VoiceOver has no - /// other way to learn: the caret moves in a view the tab has no relationship to. - let revealsStatement: Bool - - @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] : []) - .accessibilityHint(revealsStatement && !isActive ? Text(revealHint) : Text("")) - .accessibilityAction(named: Text(pinActionTitle), onTogglePin) - } - - private var revealHint: String { - String(localized: "Moves the editor cursor to the statement that produced this result") - } - - private var pill: some View { - Button(action: onActivate) { - HStack(spacing: 4) { - closeControl - Text(label) - .font(.callout) - .lineLimit(1) - .foregroundStyle(isActive ? AnyShapeStyle(.primary) : AnyShapeStyle(.secondary)) - pinControl - } - .padding(.horizontal, 8) - .padding(.vertical, 5) - .background(background, in: RoundedRectangle(cornerRadius: 6)) - .frame(maxHeight: .infinity) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .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 { - guard isPinned else { return label } - return String(format: String(localized: "%@, pinned"), label) - } - - private var background: AnyShapeStyle { - if isActive { - AnyShapeStyle(.tint.opacity(0.18)) - } else if isHovering { - AnyShapeStyle(.quaternary) - } else { - AnyShapeStyle(.clear) - } - } - - private static let controlSize: CGFloat = 12 -} diff --git a/TableProTests/Models/Query/QueryCommandAvailabilityTests.swift b/TableProTests/Models/Query/QueryCommandAvailabilityTests.swift new file mode 100644 index 0000000000..eee14b891e --- /dev/null +++ b/TableProTests/Models/Query/QueryCommandAvailabilityTests.swift @@ -0,0 +1,108 @@ +// +// QueryCommandAvailabilityTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("QueryCommandAvailability") +struct QueryCommandAvailabilityTests { + @Test("A connected tab with text can run, explain, format and favorite") + func liveTab() { + let commands = Self.make() + + #expect(commands.canRun) + #expect(commands.canExplain) + #expect(commands.canFormat) + #expect(commands.canSaveAsFavorite) + #expect(commands.canStop == false) + } + + @Test("An empty editor offers nothing to run, explain, format or favorite") + func emptyEditor() { + let commands = Self.make(hasQueryText: false) + + #expect(commands.canRun == false) + #expect(commands.canExplain == false) + #expect(commands.canFormat == false) + #expect(commands.canSaveAsFavorite == false) + #expect(commands.canClearQuery == false) + } + + /// Formatting rewrites text the reader already has. Gating it on the session made the one + /// command that needs no server unavailable exactly when the server was the problem. + @Test("Format and Favorite do not wait for a session") + func formatIgnoresTheSession() { + let commands = Self.make(isConnected: false) + + #expect(commands.canFormat) + #expect(commands.canSaveAsFavorite) + #expect(commands.canRun == false) + #expect(commands.canExplain == false) + } + + /// Run and Stop are one control, so they must never both be actionable or both be dead. + @Test("Run and Stop are exactly one actionable control in every state") + func runAndStopAreExclusive() { + for isConnected in [true, false] { + for hasText in [true, false] { + for isExecuting in [true, false] { + let commands = Self.make( + isConnected: isConnected, + hasQueryText: hasText, + isExecuting: isExecuting + ) + #expect(!(commands.canRun && commands.canStop)) + if isExecuting { + #expect(commands.canStop) + #expect(commands.canRun == false) + } + } + } + } + } + + @Test("An engine that cannot explain does not offer Explain") + func noExplainVariants() { + let commands = Self.make(explainVariants: []) + + #expect(commands.canExplain == false) + #expect(commands.explainHint.contains("does not explain")) + } + + /// A dimmed control that does not say why is the one thing a reader cannot act on. + @Test("A blocked command says why in its hint") + func hintsExplainWhyBlocked() { + #expect(Self.make(hasQueryText: false).runHint.contains("nothing to run")) + #expect(Self.make(isExecuting: true).runHint.contains("already running")) + #expect(Self.make(isConnected: false).runHint.contains("not available")) + #expect(Self.make(hasQueryText: false).formatHint.contains("nothing to format")) + } + + @Test("Clear Results follows the results, not the query text") + func clearResultsFollowsResults() { + #expect(Self.make(hasResults: true).canClearResults) + #expect(Self.make(hasResults: false).canClearResults == false) + #expect(Self.make(hasQueryText: false, hasResults: true).canClearResults) + } + + private static func make( + isConnected: Bool = true, + hasQueryText: Bool = true, + isExecuting: Bool = false, + hasResults: Bool = true, + explainVariants: [ExplainVariant] = [ExplainVariant(id: "plain", label: "Explain", sqlPrefix: "EXPLAIN")] + ) -> QueryCommandAvailability { + QueryCommandAvailability( + isConnected: isConnected, + hasQueryText: hasQueryText, + isExecuting: isExecuting, + hasResults: hasResults, + explainVariants: explainVariants, + shortcutHint: { label, _ in label } + ) + } +} diff --git a/TableProTests/Models/Query/QueryResultPresentationTests.swift b/TableProTests/Models/Query/QueryResultPresentationTests.swift new file mode 100644 index 0000000000..001fb20baf --- /dev/null +++ b/TableProTests/Models/Query/QueryResultPresentationTests.swift @@ -0,0 +1,250 @@ +// +// QueryResultPresentationTests.swift +// TableProTests +// +// The results pane used to decide what it showed inside a SwiftUI body: a switch over the view +// mode whose arms repeated the result chrome, around a nested if/else chain whose branches each +// retested "has executed and is not executing" beside a different companion. None of it could be +// checked without mounting a view. These are the states that matrix can reach. +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("QueryResultPresentation") +struct QueryResultPresentationTests { + @Test("A fresh query tab shows nothing rather than an empty grid") + func idleTab() { + let presentation = QueryResultPresentation(inputs: QueryResultInputs()) + + #expect(presentation.content == .idle) + #expect(presentation.showsResultSetSelector == false) + #expect(presentation.showsErrorBanner == false) + } + + /// A table tab describes a table whether or not its rows have arrived, so the idle rule above + /// must not reach it: retargeting empties the buffer before the replacing fetch starts. + @Test("A table tab with no rows yet still draws its grid") + func freshTableTabKeepsGrid() { + var inputs = QueryResultInputs() + inputs.tabType = .table + + #expect(QueryResultPresentation(inputs: inputs).content == .grid) + } + + @Test("A fetch with no loaded buffer reports itself rather than claiming no rows") + func executingWithNoBuffer() { + var inputs = QueryResultInputs() + inputs.isExecuting = true + + #expect(QueryResultPresentation(inputs: inputs).content == .executing) + } + + /// Retargeting a tab empties its buffer before the replacing fetch starts. Reporting "no rows" + /// there states that the table the reader just opened is empty. + @Test("A running fetch over loaded rows keeps drawing them") + func executingOverLoadedRows() { + var inputs = QueryResultInputs() + inputs.isExecuting = true + inputs.hasExecuted = true + inputs.loadedColumnCount = 3 + inputs.loadedRowCount = 12 + + #expect(QueryResultPresentation(inputs: inputs).content == .grid) + } + + @Test("Columns with no rows is a result, not an absence") + func columnsWithoutRows() { + var inputs = QueryResultInputs() + inputs.hasExecuted = true + inputs.hasActiveResultSet = true + inputs.activeResultHasColumns = true + inputs.loadedColumnCount = 4 + inputs.loadedRowCount = 0 + inputs.activeResultExecutionTime = 0.25 + + #expect(QueryResultPresentation(inputs: inputs).content == .noRows(executionTime: 0.25)) + } + + /// A filter that matched nothing must keep the grid, because the filter chrome above it is the + /// only way the reader gets their rows back. + @Test("A filter that matched nothing keeps the grid") + func filteredToNothingKeepsGrid() { + var inputs = QueryResultInputs() + inputs.hasExecuted = true + inputs.hasActiveResultSet = true + inputs.activeResultHasColumns = true + inputs.loadedColumnCount = 4 + inputs.loadedRowCount = 0 + inputs.hasAppliedFilters = true + + #expect(QueryResultPresentation(inputs: inputs).content == .grid) + } + + @Test("A statement that reports work rather than rows shows the success view") + func statementSucceeded() { + var inputs = QueryResultInputs() + inputs.hasExecuted = true + inputs.hasActiveResultSet = true + inputs.activeResultHasColumns = false + inputs.activeResultRowsAffected = 7 + inputs.activeResultExecutionTime = 0.1 + inputs.activeResultStatusMessage = "OK" + + #expect(QueryResultPresentation(inputs: inputs).content == .statementSucceeded( + rowsAffected: 7, + executionTime: 0.1, + statusMessage: "OK" + )) + } + + @Test("A failed execution shows the banner and does not claim success") + func failedExecution() { + var inputs = QueryResultInputs() + inputs.hasExecuted = true + inputs.hasActiveResultSet = true + inputs.executionErrorMessage = "syntax error" + + let presentation = QueryResultPresentation(inputs: inputs) + + #expect(presentation.showsErrorBanner) + if case .statementSucceeded = presentation.content { + Issue.record("A failed execution must never resolve to the success view") + } + } + + @Test("A query plan replaces the content and keeps the bar that chooses it") + func queryPlanOwnsThePane() { + var inputs = QueryResultInputs() + inputs.hasExecuted = true + inputs.isExplainResult = true + inputs.hasActiveResultSet = true + inputs.resultSetCount = 1 + + let presentation = QueryResultPresentation(inputs: inputs) + + #expect(presentation.content == .queryPlan) + /// The bar stays. It is the only thing carrying the result chooser now, so a plan that + /// gave it up could not be switched away from or pinned. What a plan gives up there is the + /// row readout, which `ResultStatusModel` drops from `StatusBarSnapshot.isQueryPlan`. + #expect(presentation.showsStatusBar) + #expect(presentation.showsResultSetSelector) + } + + @Test("Structure mode needs a table to show the structure of") + func structureNeedsATable() { + var inputs = QueryResultInputs() + inputs.viewMode = .structure + inputs.tableName = "orders" + + #expect(QueryResultPresentation(inputs: inputs).content == .structure(tableName: "orders")) + + inputs.tableName = nil + #expect(QueryResultPresentation(inputs: inputs).content == .idle) + + inputs.tableName = "" + #expect(QueryResultPresentation(inputs: inputs).content == .idle) + } + + @Test("Chart and map say so when there is nothing loaded to draw") + func chartAndMapWithoutData() { + for mode in [ResultsViewMode.chart, .map] { + var inputs = QueryResultInputs() + inputs.viewMode = mode + inputs.hasActiveResultSet = false + + #expect(QueryResultPresentation(inputs: inputs).content == .unavailable(mode: mode)) + } + } + + @Test("Chart and map draw once a result is loaded") + func chartAndMapWithData() { + var chart = QueryResultInputs() + chart.viewMode = .chart + chart.hasActiveResultSet = true + chart.activeResultHasColumns = true + chart.loadedColumnCount = 2 + #expect(QueryResultPresentation(inputs: chart).content == .chart) + + var map = chart + map.viewMode = .map + #expect(QueryResultPresentation(inputs: map).content == .map) + } + + @Test("The structure editor is not a result, so it offers no result chooser") + func structureHasNoChooser() { + var inputs = QueryResultInputs() + inputs.viewMode = .structure + inputs.tableName = "orders" + inputs.resultSetCount = 3 + + #expect(QueryResultPresentation(inputs: inputs).showsResultSetSelector == false) + } + + @Test("Only a query tab chooses between result sets") + func onlyQueryTabsChoose() { + var inputs = QueryResultInputs() + inputs.resultSetCount = 2 + inputs.tabType = .table + + #expect(QueryResultPresentation(inputs: inputs).showsResultSetSelector == false) + } + + @Test("The find bar belongs to table tabs") + func findBarIsTableOnly() { + var inputs = QueryResultInputs() + inputs.isFindBarVisible = true + inputs.tabType = .query + #expect(QueryResultPresentation(inputs: inputs).showsFindBar == false) + + inputs.tabType = .table + #expect(QueryResultPresentation(inputs: inputs).showsFindBar) + } + + @Test("Filter chrome follows the mode that can be filtered") + func filterChromeFollowsMode() { + var inputs = QueryResultInputs() + inputs.tabType = .table + inputs.isFilterPanelVisible = true + + inputs.viewMode = .data + #expect(QueryResultPresentation(inputs: inputs).showsFilterChrome) + + inputs.viewMode = .map + #expect(QueryResultPresentation(inputs: inputs).showsFilterChrome == false) + } + + /// The property the old nested `if/else` only had by accident. + @Test("Every reachable input resolves to exactly one content case") + func everyStateResolvesOnce() { + var seen: Set = [] + for viewMode in [ResultsViewMode.data, .structure, .json, .chart, .map] { + for isExecuting in [true, false] { + for hasExecuted in [true, false] { + for hasResultSet in [true, false] { + for hasColumns in [true, false] { + for rowCount in [0, 5] { + var inputs = QueryResultInputs() + inputs.viewMode = viewMode + inputs.tableName = "orders" + inputs.isExecuting = isExecuting + inputs.hasExecuted = hasExecuted + inputs.hasActiveResultSet = hasResultSet + inputs.activeResultHasColumns = hasColumns + inputs.loadedColumnCount = hasColumns ? 3 : 0 + inputs.loadedRowCount = rowCount + inputs.resultSetCount = hasResultSet ? 1 : 0 + + let content = QueryResultPresentation(inputs: inputs).content + seen.insert("\(content)") + } + } + } + } + } + } + + #expect(!seen.isEmpty) + } +} diff --git a/TableProTests/Models/Query/ResultSetMenuModelTests.swift b/TableProTests/Models/Query/ResultSetMenuModelTests.swift new file mode 100644 index 0000000000..13abefdfb9 --- /dev/null +++ b/TableProTests/Models/Query/ResultSetMenuModelTests.swift @@ -0,0 +1,96 @@ +// +// ResultSetMenuModelTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("ResultSetMenuModel") +struct ResultSetMenuModelTests { + /// The count is what the deleted strip showed at a glance and a closed menu cannot. Carrying it + /// in the button's own title is the whole mitigation, so it is worth a test. + @Test("The title counts the results when there is more than one") + func titleCountsResults() { + let model = Self.make(count: 4, activeIndex: 1) + + #expect(model.title == "Result 2 of 4") + } + + @Test("A single result is named rather than counted") + func singleResultIsNamed() { + let model = Self.make(count: 1, activeIndex: 0) + + #expect(model.title == "Result 1") + } + + @Test("A pinned result cannot be closed") + func pinnedCannotClose() { + let model = Self.make(count: 2, activeIndex: 0, pinnedIndices: [0]) + guard let active = model.activeEntry else { + Issue.record("Expected an active entry") + return + } + + #expect(active.isPinned) + #expect(model.canClose(active) == false) + } + + @Test("Close Others is offered only when another result can actually go") + func closeOthersNeedsAClosableSibling() { + let onlyPinnedSiblings = Self.make(count: 2, activeIndex: 0, pinnedIndices: [0, 1]) + guard let active = onlyPinnedSiblings.activeEntry else { + Issue.record("Expected an active entry") + return + } + #expect(onlyPinnedSiblings.canCloseOthers(active) == false) + + let hasClosableSibling = Self.make(count: 2, activeIndex: 0, pinnedIndices: [0]) + guard let stillActive = hasClosableSibling.activeEntry else { + Issue.record("Expected an active entry") + return + } + #expect(hasClosableSibling.canCloseOthers(stillActive)) + } + + @Test("A lone result offers nothing to close beside it") + func loneResultHasNoOthers() { + let model = Self.make(count: 1, activeIndex: 0) + guard let active = model.activeEntry else { + Issue.record("Expected an active entry") + return + } + + #expect(model.canCloseOthers(active) == false) + } + + @Test("An empty model draws no control") + func emptyModelIsEmpty() { + let model = ResultSetMenuModel(entries: [], activeOrdinal: 0, total: 0) + + #expect(model.isEmpty) + #expect(model.activeEntry == nil) + } + + private static func make( + count: Int, + activeIndex: Int, + pinnedIndices: Set = [] + ) -> ResultSetMenuModel { + let entries = (0 ..< count).map { index in + ResultSetMenuEntry( + id: UUID(), + label: "Result \(index + 1)", + isPinned: pinnedIndices.contains(index), + isActive: index == activeIndex, + ordinal: index + 1 + ) + } + return ResultSetMenuModel( + entries: entries, + activeOrdinal: activeIndex + 1, + total: count + ) + } +} diff --git a/TableProTests/Models/Query/ResultSetPolicyTests.swift b/TableProTests/Models/Query/ResultSetPolicyTests.swift new file mode 100644 index 0000000000..7021f70e06 --- /dev/null +++ b/TableProTests/Models/Query/ResultSetPolicyTests.swift @@ -0,0 +1,135 @@ +// +// ResultSetPolicyTests.swift +// TableProTests +// +// Guards the invariant behind #1982: a result the View menu says can be pinned always has a +// control on screen to pin it from. The control used to be the result strip; it is the status +// bar's result-set chooser now, and the invariant is unchanged. +// + +import Foundation +@testable import TablePro +import Testing + +@MainActor +@Suite("ResultSetPolicy") +struct ResultSetPolicyTests { + @Test("A query tab with a result offers the chooser and can pin") + func queryTabWithResult() { + let display = Self.makeDisplay() + + #expect(Self.showsChooser(tabType: .query, display: display)) + #expect(ResultSetPolicy.canPin(tabType: .query, display: display)) + } + + @Test("JSON view keeps the chooser so its results stay switchable and pinnable") + func jsonViewKeepsChooser() { + var display = Self.makeDisplay() + display.resultsViewMode = .json + + #expect(Self.showsChooser(tabType: .query, display: display)) + #expect(ResultSetPolicy.canPin(tabType: .query, display: display)) + } + + @Test("Chart view keeps the chooser so each result keeps its own configuration") + func chartViewKeepsChooser() { + var display = Self.makeDisplay() + display.resultsViewMode = .chart + + #expect(Self.showsChooser(tabType: .query, display: display)) + #expect(ResultSetPolicy.canPin(tabType: .query, display: display)) + } + + @Test("Structure view has no chooser and nothing to pin") + func structureViewHasNoChooser() { + var display = Self.makeDisplay() + display.resultsViewMode = .structure + + #expect(Self.showsChooser(tabType: .query, display: display) == false) + #expect(ResultSetPolicy.canPin(tabType: .query, display: display) == false) + } + + @Test("An explain result is a result set, so it offers the chooser and can be pinned") + func explainBehavesLikeAResultSet() { + var display = Self.makeDisplay() + let plan = ExplainResultSetFactory.make( + rawText: "Seq Scan on orders", plan: nil, sql: "EXPLAIN SELECT 1", executionTime: 0.2 + ) + display.resultSets = [plan] + display.activeResultSetId = plan.id + + #expect(Self.showsChooser(tabType: .query, display: display)) + #expect(ResultSetPolicy.canPin(tabType: .query, display: display)) + #expect(display.activeExplainResult?.id == plan.id) + } + + @Test("A tab with no results has no chooser and nothing to pin") + func emptyResultsHaveNothingToPin() { + let display = TabDisplayState() + + #expect(Self.showsChooser(tabType: .query, display: display) == false) + #expect(ResultSetPolicy.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(Self.showsChooser(tabType: tabType, display: display) == false) + #expect(ResultSetPolicy.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(ResultSetPolicy.canPin(tabType: .query, display: display)) + } + + /// The whole reason the chooser is shown at a single result rather than hidden: Pin lives in + /// its menu, so a hidden chooser is an unpinnable result. + @Test("A result is never pinnable without a chooser to pin it from") + func pinningNeverOutrunsTheChooser() { + var states: [TabDisplayState] = [TabDisplayState(), Self.makeDisplay()] + for mode in [ResultsViewMode.data, .structure, .json, .chart, .map] { + var display = Self.makeDisplay() + display.resultsViewMode = mode + states.append(display) + + var withoutResults = display + withoutResults.resultSets = [] + withoutResults.activeResultSetId = nil + states.append(withoutResults) + } + + let tabTypes: [TabType] = [.query, .table, .createTable, .erDiagram, .serverDashboard, .usersRoles] + for display in states { + for tabType in tabTypes { + let canPin = ResultSetPolicy.canPin(tabType: tabType, display: display) + #expect(!canPin || Self.showsChooser(tabType: tabType, display: display)) + } + } + } + + /// Asks the resolver, so this suite fails if the pane and the bar ever stop agreeing about + /// whether there is a result to choose between. + private static func showsChooser(tabType: TabType, display: TabDisplayState) -> Bool { + var inputs = QueryResultInputs() + inputs.tabType = tabType + inputs.viewMode = display.resultsViewMode + inputs.resultSetCount = display.resultSets.count + return QueryResultPresentation(inputs: inputs).showsResultSetSelector + } + + 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/Models/Query/ResultTabBarPolicyTests.swift b/TableProTests/Models/Query/ResultTabBarPolicyTests.swift deleted file mode 100644 index 13aa2b4dc8..0000000000 --- a/TableProTests/Models/Query/ResultTabBarPolicyTests.swift +++ /dev/null @@ -1,123 +0,0 @@ -// -// 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("Chart view keeps the strip so each result keeps its own configuration") - func chartViewKeepsStrip() { - var display = Self.makeDisplay() - display.resultsViewMode = .chart - - #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 a result set, so it shows the strip and can be pinned") - @MainActor - func explainBehavesLikeAResultSet() { - var display = Self.makeDisplay() - let plan = ExplainResultSetFactory.make( - rawText: "Seq Scan on orders", plan: nil, sql: "EXPLAIN SELECT 1", executionTime: 0.2 - ) - display.resultSets = [plan] - display.activeResultSetId = plan.id - - #expect(ResultTabBarPolicy.showsTabBar(tabType: .query, display: display)) - #expect(ResultTabBarPolicy.canPin(tabType: .query, display: display)) - #expect(display.activeExplainResult?.id == plan.id) - } - - @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, .chart] { - var display = Self.makeDisplay() - display.resultsViewMode = mode - states.append(display) - - var explaining = display - explaining.resultSets = [] - 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/Services/MainWindowToolbarValidationTests.swift b/TableProTests/Services/MainWindowToolbarValidationTests.swift index 203e25d684..68ed63a69b 100644 --- a/TableProTests/Services/MainWindowToolbarValidationTests.swift +++ b/TableProTests/Services/MainWindowToolbarValidationTests.swift @@ -467,6 +467,7 @@ struct MainWindowToolbarValidationTests { toolbarState: ConnectionToolbarState() ) } + } @MainActor diff --git a/TableProTests/Views/Main/ResultPinningTests.swift b/TableProTests/Views/Main/ResultPinningTests.swift index 7aaf5881c4..47048ba638 100644 --- a/TableProTests/Views/Main/ResultPinningTests.swift +++ b/TableProTests/Views/Main/ResultPinningTests.swift @@ -234,7 +234,7 @@ struct ResultPinningTests { let tab = try #require(coordinator.tabManager.selectedTab) #expect( coordinator.canPinActiveResultSet - == ResultTabBarPolicy.canPin(tabType: tab.tabType, display: tab.display) + == ResultSetPolicy.canPin(tabType: tab.tabType, display: tab.display) ) } } diff --git a/TableProTests/Views/Main/ResultStatusBarLayoutTests.swift b/TableProTests/Views/Main/ResultStatusBarLayoutTests.swift index df45a733bb..f6b37bce9d 100644 --- a/TableProTests/Views/Main/ResultStatusBarLayoutTests.swift +++ b/TableProTests/Views/Main/ResultStatusBarLayoutTests.swift @@ -92,6 +92,11 @@ struct ResultStatusBarLayoutTests { ), isRefreshingSchema: false, viewMode: .constant(viewMode), + resultSetMenu: ResultSetMenuModel(entries: [], activeOrdinal: 0, total: 0), + onActivateResultSet: { _ in }, + onToggleResultSetPin: { _ in }, + onCloseResultSet: { _ in }, + onCloseOtherResultSets: { _ in }, onToggleFilters: {}, onFetchAll: {}, onStructureAdd: {}, diff --git a/TableProUITests/QueryExecuteMenuUITests.swift b/TableProUITests/QueryExecuteMenuUITests.swift deleted file mode 100644 index d429599e92..0000000000 --- a/TableProUITests/QueryExecuteMenuUITests.swift +++ /dev/null @@ -1,72 +0,0 @@ -// -// QueryExecuteMenuUITests.swift -// TableProUITests -// -// Covers #2230: running every statement in the tab must be reachable from the editor's own -// Execute button. It shipped as a menu-bar command and a chord only, so users selected the -// whole tab by hand instead. -// - -import XCTest - -final class QueryExecuteMenuUITests: UITestCase { - func testExecuteMenuOffersExecuteAllStatements() throws { - let app = try launchWithSampleDatabase() - - app.typeKey("t", modifierFlags: .command) - let queryEditor = editorTextView(in: app) - XCTAssertTrue(queryEditor.waitToExist(timeout: 10)) - queryEditor.click() - app.typeText("SELECT 1;\nSELECT 2;") - - let menu = openExecuteMenu(in: app) - XCTAssertTrue( - menu.menuItems["Execute All Statements"].waitToExist(timeout: 5), - "The Execute button's menu must offer Execute All Statements" - ) - XCTAssertTrue( - menu.menuItems["Execute Without Limit"].exists, - "Execute Without Limit must survive alongside the new item" - ) - app.typeKey(.escape, modifierFlags: []) - } - - /// Asserting the item exists would still pass if the menu were wired to the wrong command, or - /// to nothing at all: the callback is optional, so dropping it at the call site is not even a - /// compile error. Running it and counting the results is what covers the wiring. - func testExecuteAllStatementsRunsEveryStatementInTheTab() throws { - let app = try launchWithSampleDatabase() - - app.typeKey("t", modifierFlags: .command) - let queryEditor = editorTextView(in: app) - XCTAssertTrue(queryEditor.waitToExist(timeout: 10)) - queryEditor.click() - app.typeText("SELECT 1;\nSELECT 2;") - - openExecuteMenu(in: app).menuItems["Execute All Statements"].click() - - let resultTabs = app.windows.firstMatch.buttons.matching(identifier: "result-tab") - XCTAssertTrue( - waitForPredicate(timeout: 30) { resultTabs.count == 2 }, - "Both statements must run, one result tab each, without selecting anything first" - ) - } - - /// The control is a split button: its leading half runs the query and only its trailing - /// chevron opens the menu, so a plain `click()` would execute instead of opening. - /// - /// The opened menu is scoped to the window rather than matched by identifier, because the CI - /// runner's macOS build exposes a just-opened menu without one (see `ResultTabPinUITests`). - private func openExecuteMenu(in app: XCUIApplication) -> XCUIElement { - let window = app.windows.firstMatch - let executeMenu = window.descendants(matching: .any) - .matching(identifier: "query-execute-menu") - .firstMatch - XCTAssertTrue( - waitUntilHittable(executeMenu, timeout: 10), - "The editor toolbar must expose the Execute split button" - ) - executeMenu.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.5)).click() - return window.menus.firstMatch - } -} diff --git a/TableProUITests/QueryPlanResultUITests.swift b/TableProUITests/QueryPlanResultUITests.swift index 084b87b194..323957028a 100644 --- a/TableProUITests/QueryPlanResultUITests.swift +++ b/TableProUITests/QueryPlanResultUITests.swift @@ -25,10 +25,12 @@ final class QueryPlanResultUITests: UITestCase { let app = try launchWithSampleDatabase() runQuery("EXPLAIN QUERY PLAN SELECT * FROM Track;", in: app) - let resultTab = app.buttons["result-tab"].firstMatch + let chooser = app.windows.firstMatch.descendants(matching: .any) + .matching(identifier: "result-set-menu") + .firstMatch XCTAssertTrue( - resultTab.waitToExist(timeout: 20), - "A plan must arrive as a result tab, not as a takeover of the results pane" + chooser.waitToExist(timeout: 20), + "A plan must arrive as a result set, not as a takeover of the results pane" ) let modePicker = app.radioGroups["query-plan-mode-picker"].firstMatch @@ -48,18 +50,22 @@ final class QueryPlanResultUITests: UITestCase { let detail = app.descendants(matching: .any).matching(identifier: "query-plan-detail-pane").firstMatch XCTAssertTrue(detail.waitToExist(timeout: 10), "Selecting a step must fill the detail pane") - resultTab.rightClick() - - /// A contextual menu opens inside the window; the menu-bar menus hang off `MenuBar`, so - /// scoping to the window isolates the one that just opened. Matching on the menu's - /// accessibility identifier instead worked here but not on the CI runner, whose macOS - /// build exposes the menu without it. - let contextMenu = app.windows.firstMatch.menus.firstMatch + /// A plan keeps the status bar so it stays choosable and pinnable. It gives up the row + /// readout there and nothing else, which is what makes the bar under a plan a footer rather + /// than a claim about rows the plan does not have. + XCTAssertTrue(waitUntilHittable(chooser, timeout: 10)) + chooser.click() + + /// The pull-down opens inside the window; the menu-bar menus hang off `MenuBar`, so scoping + /// to the window isolates the one that just opened. Matching on the menu's accessibility + /// identifier instead worked here but not on the CI runner, whose macOS build exposes a + /// just-opened menu without one. + let chooserMenu = app.windows.firstMatch.menus.firstMatch XCTAssertTrue( - contextMenu.menuItems["Pin Result"].waitToExist(timeout: 5), + chooserMenu.menuItems["Pin Result"].waitToExist(timeout: 5), "A plan is a result set, so it must offer Pin Result" ) - contextMenu.menuItems["Pin Result"].click() + chooserMenu.menuItems["Pin Result"].click() let menuBar = app.menuBars.firstMatch menuBar.menuBarItems["View"].click() diff --git a/TableProUITests/QueryRunUITests.swift b/TableProUITests/QueryRunUITests.swift new file mode 100644 index 0000000000..929cfc17d4 --- /dev/null +++ b/TableProUITests/QueryRunUITests.swift @@ -0,0 +1,83 @@ +// +// QueryRunUITests.swift +// TableProUITests +// +// Covers #2230: running every statement in the tab must be reachable from a control, not just a +// menu-bar command and a chord. The control is the Run split button in the query tab's own command +// bar, which is where it has to live: an NSToolbar belongs to the window, and a window here hosts +// table, structure and diagram tabs that have nothing to run. +// + +import XCTest + +final class QueryRunUITests: UITestCase { + func testRunMenuOffersRunAllStatements() throws { + let app = try launchWithSampleDatabase() + + app.typeKey("t", modifierFlags: .command) + let queryEditor = editorTextView(in: app) + XCTAssertTrue(queryEditor.waitToExist(timeout: 10)) + queryEditor.click() + app.typeText("SELECT 1;\nSELECT 2;") + + let menu = openRunMenu(in: app) + XCTAssertTrue( + menu.menuItems["Run All Statements"].waitToExist(timeout: 5), + "The Run item's menu must offer Run All Statements" + ) + XCTAssertTrue( + menu.menuItems["Run Without Limit"].exists, + "Run Without Limit must survive alongside it" + ) + XCTAssertTrue( + menu.menuItems["Clear Query"].exists, + "Clear Query lost its own button and rides this menu instead" + ) + app.typeKey(.escape, modifierFlags: []) + } + + /// Asserting the item exists would still pass if it were wired to the wrong command or to + /// nothing at all. Running it and counting the results is what covers the wiring. + func testRunAllStatementsRunsEveryStatementInTheTab() throws { + let app = try launchWithSampleDatabase() + + app.typeKey("t", modifierFlags: .command) + let queryEditor = editorTextView(in: app) + XCTAssertTrue(queryEditor.waitToExist(timeout: 10)) + queryEditor.click() + app.typeText("SELECT 1;\nSELECT 2;") + + openRunMenu(in: app).menuItems["Run All Statements"].click() + + let chooser = app.windows.firstMatch.descendants(matching: .any) + .matching(identifier: "result-set-menu") + .firstMatch + XCTAssertTrue( + chooser.waitToExist(timeout: 30), + "Two statements must produce two results, which the status bar's chooser reports" + ) + XCTAssertTrue( + waitForPredicate(timeout: 10) { chooser.label.contains("2") }, + "The chooser's title counts the results: expected it to name two, got \(chooser.label)" + ) + } + + /// The toolbar item is a split button: its body runs the query and only its trailing chevron + /// opens the menu, so a plain `click()` would execute instead of opening. + /// + /// The opened menu is scoped to the window rather than matched by identifier, because the CI + /// runner's macOS build exposes a just-opened menu without one. + @discardableResult + private func openRunMenu(in app: XCUIApplication) -> XCUIElement { + let window = app.windows.firstMatch + let run = window.descendants(matching: .any) + .matching(identifier: "query-run") + .firstMatch + XCTAssertTrue( + waitUntilHittable(run, timeout: 10), + "The query tab's command bar must expose the Run split button" + ) + run.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.5)).click() + return window.menus.firstMatch + } +} diff --git a/TableProUITests/ResultSetPinUITests.swift b/TableProUITests/ResultSetPinUITests.swift new file mode 100644 index 0000000000..b61f1e6530 --- /dev/null +++ b/TableProUITests/ResultSetPinUITests.swift @@ -0,0 +1,55 @@ +// +// ResultSetPinUITests.swift +// TableProUITests +// +// Covers #1982: pinning a query result must be reachable from a control, not the View menu alone. +// That control used to be the result strip's own tab; it is the status bar's result-set chooser +// now, which is why the chooser is shown even for a single result. +// + +import XCTest + +final class ResultSetPinUITests: UITestCase { + func testResultSetChooserOffersPinForASingleResult() throws { + let app = try launchWithSampleDatabase() + + app.typeKey("t", modifierFlags: .command) + let queryEditor = editorTextView(in: app) + XCTAssertTrue(queryEditor.waitToExist(timeout: 10)) + queryEditor.click() + app.typeText("SELECT 1;") + app.typeKey(.return, modifierFlags: .command) + + let chooser = app.windows.firstMatch.descendants(matching: .any) + .matching(identifier: "result-set-menu") + .firstMatch + XCTAssertTrue( + chooser.waitToExist(timeout: 20), + "A single result still offers the chooser, because Pin lives in its menu" + ) + XCTAssertTrue(waitUntilHittable(chooser, timeout: 10)) + chooser.click() + + /// The pull-down opens inside the window; the menu-bar menus hang off `MenuBar`, so scoping + /// to the window isolates the one that just opened. Matching on the menu's accessibility + /// identifier instead worked locally but not on the CI runner, whose macOS build exposes a + /// just-opened menu without one. + let menu = app.windows.firstMatch.menus.firstMatch + XCTAssertTrue( + menu.menuItems["Pin Result"].waitToExist(timeout: 5), + "The chooser's menu must offer Pin Result" + ) + XCTAssertTrue( + menu.menuItems["Close Other Results"].exists, + "Close Others survives the move off the strip" + ) + menu.menuItems["Pin Result"].click() + + let menuBar = app.menuBars.firstMatch + menuBar.menuBarItems["View"].click() + let unpinItem = menuBar.menuItems["Unpin Result"] + XCTAssertTrue(unpinItem.waitToExist(timeout: 5), "A pinned result reads as Unpin Result") + XCTAssertTrue(unpinItem.isEnabled) + app.typeKey(.escape, modifierFlags: []) + } +} diff --git a/TableProUITests/ResultStatementLinkUITests.swift b/TableProUITests/ResultStatementLinkUITests.swift index f2e5b4062f..4650db6bde 100644 --- a/TableProUITests/ResultStatementLinkUITests.swift +++ b/TableProUITests/ResultStatementLinkUITests.swift @@ -23,21 +23,22 @@ final class ResultStatementLinkUITests: UITestCase { SELECT 3; """ - func testResultTabsAreNamedAfterTheirStatements() throws { + func testResultsAreNamedAfterTheirStatements() throws { let app = try runScript() - let tabs = app.buttons.matching(identifier: "result-tab") - XCTAssertEqual(tabs.count, 3, "Three statements must produce three result tabs") + let menu = openChooser(in: app) + let items = menu.menuItems + let labels = (0 ..< items.count).map { items.element(boundBy: $0).label } - let labels = (0.. XCUIElement { + let chooser = app.windows.firstMatch.descendants(matching: .any) + .matching(identifier: "result-set-menu") + .firstMatch + XCTAssertTrue(waitUntilHittable(chooser, timeout: 15), "The status bar must offer the result chooser") + chooser.click() + return app.windows.firstMatch.menus.firstMatch + } + // MARK: - Harness private func runScript() throws -> XCUIApplication { @@ -74,9 +88,13 @@ final class ResultStatementLinkUITests: UITestCase { app.menuBars.firstMatch.menuBarItems["Query"].click() app.menuBars.firstMatch.menuItems["Execute All Statements"].click() + let chooser = app.windows.firstMatch.descendants(matching: .any) + .matching(identifier: "result-set-menu") + .firstMatch + XCTAssertTrue(chooser.waitToExist(timeout: 20), "The script must produce a result per statement") XCTAssertTrue( - app.buttons.matching(identifier: "result-tab").element(boundBy: 2).waitToExist(timeout: 20), - "The script must produce a result per statement" + waitForPredicate(timeout: 15) { chooser.label.contains("3") }, + "Three statements must produce three results, got \(chooser.label)" ) return app } diff --git a/TableProUITests/ResultTabPinUITests.swift b/TableProUITests/ResultTabPinUITests.swift deleted file mode 100644 index f635ddefca..0000000000 --- a/TableProUITests/ResultTabPinUITests.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// 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: UITestCase { - func testResultTabExposesPinButtonAndPinMenuItem() throws { - let app = try launchWithSampleDatabase() - - app.typeKey("t", modifierFlags: .command) - let queryEditor = editorTextView(in: app) - XCTAssertTrue(queryEditor.waitToExist(timeout: 10)) - queryEditor.click() - app.typeText(paddedQuery) - app.typeKey(.return, modifierFlags: .command) - - let resultTab = app.buttons["result-tab"].firstMatch - XCTAssertTrue(resultTab.waitToExist(timeout: 20), "The query must produce a result tab") - resultTab.rightClick() - - /// A contextual menu opens inside the window; the menu-bar menus hang off `MenuBar`, so - /// scoping to the window isolates the one that just opened. Matching on the menu's - /// accessibility identifier instead worked here but not on the CI runner, whose macOS - /// build exposes the menu without it. - let contextMenu = app.windows.firstMatch.menus.firstMatch - XCTAssertTrue( - contextMenu.menuItems["Close Others"].waitToExist(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.waitToExist(timeout: 5), "A pinned result reads as Unpin Result") - XCTAssertTrue(unpinItem.isEnabled) - app.typeKey(.escape, modifierFlags: []) - } - - /// Sixty blank lines, not sixty comments. The editor ends up the same height either way, but - /// XCUITest synthesizes typing at roughly 113ms per character, so the 660-character version - /// spent 74 seconds of this test's 103 pressing keys. - private var paddedQuery: String { - String(repeating: "\n", count: 60) + "SELECT 1;" - } -} diff --git a/docs/features/explain-visualization.mdx b/docs/features/explain-visualization.mdx index b7569e0baf..b7e50be30c 100644 --- a/docs/features/explain-visualization.mdx +++ b/docs/features/explain-visualization.mdx @@ -5,7 +5,7 @@ description: View query execution plans as diagrams, trees, or raw output **EXPLAIN** estimates. **EXPLAIN ANALYZE** runs the query and reports what actually happened. Choose between them from the dropdown beside **Explain** in the query editor toolbar, or use **Query > Explain Query** (`Cmd+Option+E`); engines with a single variant show a plain button instead. -The plan arrives as a result tab next to your query results, so going back to the data costs nothing, and pinning that tab keeps a plan while you try another query. Typing an `EXPLAIN` statement in the editor and running it opens the same viewer. +The plan arrives as a result beside your query results, so going back to the data costs nothing, and pinning it keeps a plan while you try another query. Typing an `EXPLAIN` statement in the editor and running it opens the same viewer. Show Query History**. There is also a **History** toolbar button, added through **View > Customize Toolbar…**. +Open it with `Cmd+Y` or **View > Show Query History**. There is also a **History** toolbar button, added through **View > Customize Toolbar…**. - - Query history drawer - Query history drawer + + Query history pane + Query history pane -## The drawer +## The pane -The drawer opens under the editor and its divider resizes it. Height, filters and whether it was open are remembered per connection. +History shares the window's trailing pane with the inspector and the assistant, so opening it shows it in place of whichever of those was there. Its entry list sits above its details and a divider between them resizes both. Width, filters and whether it was open are remembered per connection. Entries group by day, newest first, under **Today**, **Yesterday**, or the date. A row carries the outcome, the query text, the database, the time it ran and how long it took. Under a millisecond reads `<1 ms`; a step whose duration was never measured reads `–` rather than `0 ms`. -Select a row and the right pane shows the full query, highlighted for the database it ran against, with its connection, database and schema, timestamp, duration, row count, source, and the error when it failed. Where the driver separated execution from transfer, the duration is listed as its parts instead: see [How long it took](/features/query-results#how-long-it-took). The keyboard stays in the list, so arrow keys keep moving. +Select a row and the lower half shows the full query, highlighted for the database it ran against, with its connection, database and schema, timestamp, duration, row count, source, and the error when it failed. Where the driver separated execution from transfer, the duration is listed as its parts instead: see [How long it took](/features/query-results#how-long-it-took). The keyboard stays in the list, so arrow keys keep moving. Recent queries also appear in [Open Quickly](/features/open-quickly). For the summary rather than the list, see [Query Insights](/features/query-insights). @@ -34,7 +34,7 @@ Recent queries also appear in [Open Quickly](/features/open-quickly). For the su Search matches partial words, so `cust` finds `customers`. Several words must all match but need not be adjacent: `select customers` finds `SELECT id, name FROM customers`. -**All Connections** searches everything you have, and rows then name their connection, so two databases both called `app` stay apart. **Reset Filters** puts the drawer back to its defaults. +**All Connections** searches everything you have, and rows then name their connection, so two databases both called `app` stay apart. **Reset Filters** puts the pane back to its defaults. ### Sources @@ -74,11 +74,11 @@ An entry belonging to another connection loads into a new tab in that connection ## Pausing -The pause button stops recording on this Mac, from every source: row edits, structure changes, imports and AI clients included. The drawer says so until you resume. Pausing is local to the Mac you press it on and survives relaunch. +The pause button stops recording on this Mac, from every source: row edits, structure changes, imports and AI clients included. The pane says so until you resume. Pausing is local to the Mac you press it on and survives relaunch. ## Clearing history -The trash button deletes exactly the entries the drawer is listing. Anything the source, date, outcome or search filters are hiding stays, which at the default **My Queries** source spares table browsing, row edits, imports and AI queries. The confirmation names what it is about to delete. There is no undo. +The trash button deletes exactly the entries the pane is listing. Anything the source, date, outcome or search filters are hiding stays, which at the default **My Queries** source spares table browsing, row edits, imports and AI queries. The confirmation names what it is about to delete. There is no undo. **Settings > Data > Query History > Clear History…** clears everything, for every connection. diff --git a/docs/features/query-insights.mdx b/docs/features/query-insights.mdx index 634a5d5f3c..255ab0e481 100644 --- a/docs/features/query-insights.mdx +++ b/docs/features/query-insights.mdx @@ -82,7 +82,7 @@ Two things stay apart: table and column names keep their capitalization, since ` | Source | Which parts of the app the queries came from | | Date | Last hour, today, last 7 days, last 4 weeks, all time | -Source defaults to **My Queries**, the SQL you wrote yourself, and uses the same source list as the [history drawer](/features/query-history#sources). Date defaults to **Last 4 Weeks** rather than All Time, since "got slower than before" needs a before to compare against. There is no outcome filter. +Source defaults to **My Queries**, the SQL you wrote yourself, and uses the same source list as [query history](/features/query-history#sources). Date defaults to **Last 4 Weeks** rather than All Time, since "got slower than before" needs a before to compare against. There is no outcome filter. Insights refreshes as you run queries. The refresh button is for when you want it now. diff --git a/docs/features/query-results.mdx b/docs/features/query-results.mdx index 26a06aafd2..be4c055e29 100644 --- a/docs/features/query-results.mdx +++ b/docs/features/query-results.mdx @@ -1,15 +1,15 @@ --- title: Query Results -description: Result tabs, pinning, the row cap, and what a failed or non-SELECT statement shows +description: Choosing between results, pinning, the row cap, and what a failed or non-SELECT statement shows --- import RowCap from "/snippets/row-cap.mdx"; -Every statement you run gets its own result tab. The next run reuses an unpinned one rather than adding to the pile, so a tab survives only as long as you leave it unpinned. +Every statement you run gets its own result. The next run reuses an unpinned one rather than adding to the pile, so a result survives only as long as you leave it unpinned. - - Query result tabs - Query result tabs + + Query results and the result chooser + Query results and the result chooser ## Naming @@ -21,23 +21,25 @@ A result is named after the table it came from. When there is no single table, t SELECT count(*) FROM orders; ``` -names its result tab `monthly totals`. Names longer than 28 characters are truncated with an ellipsis, and a result no statement stands behind falls back to `Result 1`. Rest the pointer on a tab to see the query that produced it, or the error message if it failed. +names its result `monthly totals`. Names longer than 28 characters are truncated with an ellipsis, and a result no statement stands behind falls back to `Result 1`. -## Working with the strip +## Choosing a result + +The chooser sits at the left of the bar under the grid and reads **Result 2 of 4**, or the result's own name when there is only one. A pin in front of the title means the result on screen is pinned. | Action | How | |---|---| -| Switch result | `Cmd+Option+[` and `Cmd+Option+]`, or click the tab | -| Pin | The pin on the right of the tab, `Cmd+Option+P`, or right-click > **Pin Result** | -| Close | `Cmd+Shift+W`, or right-click > **Close** | -| Close the rest | Right-click > **Close Others** | +| Switch result | `Cmd+Option+[` and `Cmd+Option+]`, or pick it from the chooser | +| Pin | Chooser > **Pin Result**, `Cmd+Option+P`, or **View > Pin Result** | +| Close | Chooser > **Close Result**, or `Cmd+Shift+W` | +| Close the rest | Chooser > **Close Other Results** | | Show or hide the panel | `Cmd+Option+R`, or **View > Show Results** | -A pinned result moves to the front of the strip, and the next query opens a new tab instead of overwriting it. Pinned tabs cannot be closed or cleared until you unpin them, which is what makes them useful for comparing two runs side by side. +The next query opens a new result instead of overwriting a pinned one. Pinned results cannot be closed or cleared until you unpin them, which is what makes them useful for comparing two runs. Picking a result moves the editor cursor to the statement that produced it and unfolds that statement if it was collapsed. Picking the result already showing does nothing, so clicking between pinned results never moves the editor under you. A statement you have since edited away leaves the cursor where it is. -The results panel expands itself when a query runs. The editor's trash button clears the query and the results together; to keep the query, right-click the results and choose **Clear Results**. +The results panel expands itself when a query runs. **Query > Clear Results** empties it and leaves the query alone; **Query > Clear Query** empties the editor and leaves the results. Both are also on the **Run** button's menu. ## How long it took @@ -62,7 +64,7 @@ A query that returns rows and carries no `LIMIT`, `FETCH FIRST` or `TOP` of its -When the cap trims a result the status bar reads **Showing N rows** and offers **Fetch All**, which extends the result in place. To skip the cap for one run, press `Cmd+Option+Enter` or choose **Execute Without Limit** from the Execute button's menu. +When the cap trims a result the status bar reads **Showing N rows** and offers **Fetch All**, which extends the result in place. To skip the cap for one run, press `Cmd+Option+Enter` or choose **Run Without Limit** from the **Run** button's menu. ## Statements that return no rows diff --git a/docs/features/sql-editor.mdx b/docs/features/sql-editor.mdx index 48a2afb3d7..66a46495bc 100644 --- a/docs/features/sql-editor.mdx +++ b/docs/features/sql-editor.mdx @@ -33,13 +33,13 @@ Instead of hardcoding a value, write `:name` and fill it in when the query runs. ## Running several statements -To run the whole tab, press `Cmd+Shift+Enter`, choose **Execute All Statements** from the Execute button's menu, or use **Query > Execute All Statements**. Nothing needs to be selected first. Select text and `Cmd+Enter` runs the selection instead. +To run the whole tab, press `Cmd+Shift+Enter`, choose **Run All Statements** from the **Run** button's menu, or use **Query > Execute All Statements**. Nothing needs to be selected first. Select text and `Cmd+Enter` runs the selection instead. A batch runs top to bottom: - On engines with transactions, the batch runs inside one. A failure stops it and rolls back everything before it. Engines without transactions run each statement as-is, with nothing to roll back. - The error names its place in the run: "Statement 3/5 failed: …". -- Each statement gets its own result tab, and each is recorded separately in [query history](/features/query-history). +- Each statement gets its own result, and each is recorded separately in [query history](/features/query-history). ## Statement markers diff --git a/docs/features/tabs.mdx b/docs/features/tabs.mdx index 0fd45aaab5..e3ddb3c5f7 100644 --- a/docs/features/tabs.mdx +++ b/docs/features/tabs.mdx @@ -82,7 +82,7 @@ Hold the drag near either end of the strip and the track scrolls under it, so a Right-click a tab for **Move Tab Left** and **Move Tab Right**, which move it one place at a time, dim at the ends of the strip, and are offered to VoiceOver as actions on the tab. -Tabs cannot be pinned. Pinning exists for result tabs inside a query tab (`Cmd+Option+P`). +Tabs cannot be pinned. Pinning exists for results inside a query tab (`Cmd+Option+P`). ## Moving a tab to its own window