diff --git a/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Batches.swift b/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Batches.swift index d29e638be4..9b13914ee1 100644 --- a/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Batches.swift +++ b/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Batches.swift @@ -414,6 +414,7 @@ extension QueryExecutionCoordinator { let errorResult = ResultSet(label: report.resultLabel) errorResult.errorMessage = message + errorResult.failedQuery = report.failedSQL errorResult.statementAnchor = failedBatch?.batch.anchor guard parent.tabExecution.settle(claim) else { return } @@ -431,8 +432,9 @@ extension QueryExecutionCoordinator { let timing = PluginQueryTiming(total: outputs.reduce(0) { $0 + $1.executionTime }) parent.flushBufferToActiveResult(tabId: tabId, pinnedOnly: true) parent.tabManager.mutate(tabId: tabId) { tab in - tab.execution.errorMessage = message - tab.execution.errorQuery = report.failedSQL + // The Error result carries the failure, so the banner stays on it rather than over every other result. + tab.execution.errorMessage = nil + tab.execution.errorQuery = nil tab.execution.executionTime = timing.total tab.execution.lastExecutedAt = Date() tab.display.replaceUnpinnedResults( @@ -491,6 +493,7 @@ extension QueryExecutionCoordinator { ) -> [ResultSet] { let databaseType = parent.connection.type let grammar = parent.lexicalGrammar + let firstNumber = parent.tabManager.tabs.first { $0.id == tabId }?.display.pinnedResultCount ?? 0 var resultSets: [ResultSet] = [] for (entry, output) in zip(prepared, outputs) { let isPlainQuery = { (sql: String) in @@ -510,7 +513,7 @@ extension QueryExecutionCoordinator { return makeStatementResultSet( result: result, sql: statement.sql, - index: resultSets.count, + index: firstNumber + resultSets.count, baseQuery: replayable ? statement.sql : nil, tabId: tabId, anchor: StatementAnchor(statement) @@ -518,11 +521,11 @@ extension QueryExecutionCoordinator { } } else if output.resultSets.isEmpty { produced = output.errors.isEmpty - ? [countResultSet(output, anchor: entry.batch.anchor, index: resultSets.count)] + ? [countResultSet(output, anchor: entry.batch.anchor, index: firstNumber + resultSets.count)] : [] } else { produced = output.resultSets.enumerated().map { offset, result in - numberedResultSet(result, anchor: entry.batch.anchor, index: resultSets.count + offset) + numberedResultSet(result, anchor: entry.batch.anchor, index: firstNumber + resultSets.count + offset) } } produced.last?.serverOutput = output.serverOutput diff --git a/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index 241ff38ad0..7ed1294821 100644 --- a/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -284,7 +284,7 @@ extension QueryExecutionCoordinator { } let rs = ResultSet( - label: ResultSet.label(tableName: tableName, anchor: anchor, index: 0), + label: ResultSet.label(tableName: tableName, anchor: anchor, index: tab.display.pinnedResultCount), tableRows: newTableRows ) rs.statementAnchor = anchor diff --git a/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift b/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift index f861379ac9..dc863a5f69 100644 --- a/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift +++ b/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift @@ -589,12 +589,13 @@ extension QueryExecutionCoordinator { } private func statementResultSets(_ history: ExecutedStatementHistory, tabId: UUID) -> [ResultSet] { - zip(history.prepared, history.results).enumerated().map { index, pair in + let firstNumber = parent.tabManager.tabs.first { $0.id == tabId }?.display.pinnedResultCount ?? 0 + return zip(history.prepared, history.results).enumerated().map { index, pair in let (statement, result) = pair return makeStatementResultSet( result: result, sql: statement.originalSQL, - index: index, + index: firstNumber + index, baseQuery: statement.executableSQL, baseQueryParameterValues: statement.parameterValues?.map { $0 as? String }, namedParameterStatement: statement.parameterValues == nil @@ -726,6 +727,7 @@ extension QueryExecutionCoordinator { let errorRS = ResultSet(label: report.resultLabel) errorRS.errorMessage = contextMsg + errorRS.failedQuery = report.failedSQL errorRS.statementAnchor = report.failedStatementIndex .flatMap { statements.indices.contains($0) ? statements[$0] : nil } .map(StatementAnchor.init) @@ -746,8 +748,9 @@ extension QueryExecutionCoordinator { parent.flushBufferToActiveResult(tabId: tabId, pinnedOnly: true) parent.tabManager.mutate(tabId: tabId) { tab in - tab.execution.errorMessage = contextMsg - tab.execution.errorQuery = failedStatementSQL + // The Error result carries the failure, so the banner stays on it rather than over every other result. + tab.execution.errorMessage = nil + tab.execution.errorQuery = nil tab.execution.executionTime = cumulativeTime tab.execution.lastExecutedAt = Date() diff --git a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift index ffed17eda9..f480747647 100644 --- a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift +++ b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift @@ -80,6 +80,9 @@ struct MenuValidationContext: Equatable { var hasOtherWindowTabs = false var canPinResultTab = false var canCompareResult = false + var canShowPreviousResult = false + var canShowNextResult = false + var canCloseResultTab = false // Separate flags: Back and Forward run out independently and a disabled item has to say which one it is. var canNavigateBack = false var canNavigateForward = false @@ -154,9 +157,6 @@ extension MainSplitViewController: NSMenuItemValidation { #selector(refreshDatabase(_:)), #selector(openQuickSwitcher(_:)), #selector(toggleQueryHistory(_:)), - #selector(showPreviousResult(_:)), - #selector(showNextResult(_:)), - #selector(closeResultTab(_:)), #selector(focusSidebarFilter(_:)), #selector(showERDiagram(_:)): return context.isConnected @@ -296,6 +296,12 @@ extension MainSplitViewController: NSMenuItemValidation { return context.canPinResultTab case #selector(compareResult(_:)): return context.canCompareResult + case #selector(showPreviousResult(_:)): + return context.canShowPreviousResult + case #selector(showNextResult(_:)): + return context.canShowNextResult + case #selector(closeResultTab(_:)): + return context.canCloseResultTab case #selector(navigateBack(_:)): return context.isConnected && context.canNavigateBack case #selector(navigateForward(_:)): @@ -461,6 +467,11 @@ extension MainSplitViewController: NSMenuItemValidation { #selector(moveTabToConnection(_:)), #selector(searchDatabase(_:)), #selector(buildJoinQuery(_:)), + #selector(pinResult(_:)), + #selector(compareResult(_:)), + #selector(showPreviousResult(_:)), + #selector(showNextResult(_:)), + #selector(closeResultTab(_:)), ] // A command this window does not own stays enabled, which keeps `performClose:` and the system's own items working. @@ -614,6 +625,9 @@ extension MainSplitViewController: NSMenuItemValidation { hasOtherWindowTabs: hasOtherWindowTabs, canPinResultTab: actions.canPinResultTab, canCompareResult: actions.canCompareResult, + canShowPreviousResult: actions.canShowPreviousResult, + canShowNextResult: actions.canShowNextResult, + canCloseResultTab: actions.canCloseResultTab, canNavigateBack: actions.canNavigateBack, canNavigateForward: actions.canNavigateForward, canNavigatePages: actions.canNavigatePages, diff --git a/apple/TablePro/Models/Query/QueryResultPresentation.swift b/apple/TablePro/Models/Query/QueryResultPresentation.swift index d960d8b15d..89c93b7d56 100644 --- a/apple/TablePro/Models/Query/QueryResultPresentation.swift +++ b/apple/TablePro/Models/Query/QueryResultPresentation.swift @@ -32,6 +32,13 @@ enum QueryResultContent: Equatable { case unavailable(mode: ResultsViewMode) } +// Where the result sets are chosen: the bar's pull-down for one, a tab row above the result for more. +enum ResultSetChooserPlacement: Equatable { + case hidden + case statusBar + case tabRow +} + // A plain struct, not a `QueryTab`, so tests reach every state without a tab manager, coordinator or session registry. struct QueryResultInputs: Equatable { var tabType: TabType = .query @@ -63,23 +70,25 @@ struct QueryResultInputs: Equatable { // Pure: every branch is decidable from `QueryResultInputs` alone, like `ResultStatusModel` and `ResultSetPolicy`. struct QueryResultPresentation: Equatable { let content: QueryResultContent - let showsResultSetSelector: Bool + let resultSetChooser: ResultSetChooserPlacement let showsFilterChrome: Bool // Always: a plan without the bar can't be switched away from or pinned. Its row readout hides via `StatusBarSnapshot.isQueryPlan`. let showsStatusBar: Bool let showsErrorBanner: Bool + var showsResultSetSelector: Bool { resultSetChooser != .hidden } + init(inputs: QueryResultInputs) { content = Self.resolveContent(inputs) - showsResultSetSelector = Self.resolvesResultSetSelector(inputs) + resultSetChooser = Self.resolveResultSetChooser(inputs) showsFilterChrome = Self.resolvesFilterChrome(inputs) showsStatusBar = true showsErrorBanner = Self.resolvedError(inputs) != nil } - // The active result's message wins since it describes the result on screen; the tab's covers a failure with no result set. + // Same order as `QueryTab.shownError`: the tab's message is the newer failure. static func resolvedError(_ inputs: QueryResultInputs) -> String? { - inputs.activeResultErrorMessage ?? inputs.executionErrorMessage + inputs.executionErrorMessage ?? inputs.activeResultErrorMessage } private static func resolveContent(_ inputs: QueryResultInputs) -> QueryResultContent { @@ -167,11 +176,14 @@ struct QueryResultPresentation: Equatable { return .noRows(executionTime: inputs.activeResultExecutionTime ?? inputs.executionTime) } - // Shown even for one result: Pin, Unpin and Close live in its menu, and it costs no height inside the status bar. - 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 + // One result still gets the pull-down: Pin, Unpin and Close live in its menu, and it costs no height inside the bar. + private static func resolveResultSetChooser(_ inputs: QueryResultInputs) -> ResultSetChooserPlacement { + guard inputs.tabType == .query, inputs.viewMode != .structure else { return .hidden } + switch inputs.resultSetCount { + case 0: return .hidden + case 1: return .statusBar + default: return .tabRow + } } private static func resolvesFilterChrome(_ inputs: QueryResultInputs) -> Bool { diff --git a/apple/TablePro/Models/Query/QueryTab+Errors.swift b/apple/TablePro/Models/Query/QueryTab+Errors.swift new file mode 100644 index 0000000000..46d0027656 --- /dev/null +++ b/apple/TablePro/Models/Query/QueryTab+Errors.swift @@ -0,0 +1,34 @@ +// +// QueryTab+Errors.swift +// TablePro +// + +import Foundation + +struct QueryTabError: Equatable { + let message: String + let query: String? + // Only the tab's own message can be dismissed: an Error result's message is that result. + let isDismissible: Bool +} + +extension QueryTab { + // The tab's message is the newer failure, so it shows ahead of the one the active result recorded when it ran. + @MainActor + var shownError: QueryTabError? { + if let message = execution.errorMessage { + return QueryTabError(message: message, query: execution.errorQuery, isDismissible: true) + } + guard let active = display.activeResultSet, let message = active.errorMessage else { return nil } + return QueryTabError(message: message, query: active.failedQuery, isDismissible: false) + } + + // What Fix with AI works from while another result is on screen: the failure still belongs to this tab. + @MainActor + var latestError: QueryTabError? { + if let shownError { return shownError } + guard let failed = display.resultSets.last(where: { $0.errorMessage != nil }), + let message = failed.errorMessage else { return nil } + return QueryTabError(message: message, query: failed.failedQuery, isDismissible: false) + } +} diff --git a/apple/TablePro/Models/Query/QueryTabState.swift b/apple/TablePro/Models/Query/QueryTabState.swift index 76f2957417..731d95864a 100644 --- a/apple/TablePro/Models/Query/QueryTabState.swift +++ b/apple/TablePro/Models/Query/QueryTabState.swift @@ -668,10 +668,17 @@ struct TabDisplayState: Equatable { leaveOutputModeWithoutOutput() } + // New results join after the pinned ones, so a counted name starts there and never repeats a pinned one. @MainActor - mutating func removeUnpinnedResults() { + var pinnedResultCount: Int { + resultSets.filter(\.isPinned).count + } + + @MainActor + mutating func removeUnpinnedResults(keepingActive: Bool = false) { + let activeId = activeResultSet?.id resultSets = resultSets.filter { $0.isPinned } - activeResultSetId = resultSets.last?.id + activeResultSetId = keepingActive && resultSets.contains { $0.id == activeId } ? activeId : resultSets.last?.id leaveOutputModeWithoutOutput() } diff --git a/apple/TablePro/Models/Query/ResultSet.swift b/apple/TablePro/Models/Query/ResultSet.swift index 3b8fa4bdef..96319f8afa 100644 --- a/apple/TablePro/Models/Query/ResultSet.swift +++ b/apple/TablePro/Models/Query/ResultSet.swift @@ -31,6 +31,8 @@ final class ResultSet: ObservableObject, Identifiable { @Published var executionTime: TimeInterval? @Published var rowsAffected: Int = 0 @Published var errorMessage: String? + // The statement an Error result reports on, so Fix with AI sends that statement rather than the whole script. + @Published var failedQuery: String? @Published var statusMessage: String? /// What the statement printed on the server, such as Oracle's `DBMS_OUTPUT`, read right after it ran. @Published var serverOutput: PluginServerOutput = .none diff --git a/apple/TablePro/Models/Query/ResultSetMenuModel.swift b/apple/TablePro/Models/Query/ResultSetMenuModel.swift index a8d74cc890..65399f008e 100644 --- a/apple/TablePro/Models/Query/ResultSetMenuModel.swift +++ b/apple/TablePro/Models/Query/ResultSetMenuModel.swift @@ -16,52 +16,63 @@ struct ResultSetMenuEntry: Equatable, Identifiable { let isComparable: Bool } -// A pull-down rather than tabs: macOS has no native tab control that closes or pins anything but windows. +// Backs both the tab row and the pull-down, so the two never disagree on names or on what can close. struct ResultSetMenuModel: Equatable { let entries: [ResultSetMenuEntry] let activeOrdinal: Int let total: Int + // HIG, Tab views: "Avoid providing more than six tabs". Past that the pull-down alone lists them. + static let maximumTabCount = 6 + 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 offersTabs: Bool { + (2 ... Self.maximumTabCount).contains(total) + } + + // A position, not a name: a counted name such as "Result 3" keeps its number after an earlier result closes. var title: String { - guard total > 1 else { return entries.first?.label ?? "" } - return String( - format: String(localized: "Result %1$d of %2$d"), - activeOrdinal, - total - ) + guard total > 1 else { return entries.first.map(displayLabel(of:)) ?? "" } + return String(format: String(localized: "%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. - /// - /// A single result is named rather than counted, and a result is named after its table or its - /// leading comment, so the name is as long as the identifier. The chooser is `fixedSize`, so an - /// unbounded name here sets the bar's own floor and pushes the grid out of a narrow pane. + // The chooser is `fixedSize`, so an unbounded name here would set the bar's floor and push the grid out of a narrow pane. var compactTitle: String { - guard total > 1 else { return Self.truncated(entries.first?.label ?? "") } + guard total > 1 else { return Self.truncated(title, limit: Self.compactTitleCharacterLimit) } return "\(activeOrdinal)/\(total)" } - /// Long enough to tell two results apart, short enough that no name decides the bar's width. - /// The full name stays on the menu entry and on the control's accessibility label. - private static func truncated(_ label: String) -> String { - let value = label as NSString - guard value.length > compactTitleCharacterLimit else { return label } - return value.substring(to: compactTitleCharacterLimit) + "…" + var activeEntry: ResultSetMenuEntry? { + entries.first { $0.isActive } } - private static let compactTitleCharacterLimit = 16 + func displayLabel(of entry: ResultSetMenuEntry) -> String { + guard let index = entries.firstIndex(where: { $0.id == entry.id }) else { return entry.label } + return Self.displayLabel(at: index, in: entries.map(\.label)) + } - var activeEntry: ResultSetMenuEntry? { - entries.first { $0.isActive } + // The name is cut before the occurrence is added, so "(2)" always survives. + func tabTitle(of entry: ResultSetMenuEntry) -> String { + guard let index = entries.firstIndex(where: { $0.id == entry.id }) else { return entry.label } + let labels = entries.map(\.label) + let name = Self.truncated(entry.label, limit: Self.tabTitleCharacterLimit) + guard let occurrence = Self.occurrence(at: index, in: labels) else { return name } + return String(format: String(localized: "%1$@ (%2$d)"), name, occurrence) } - /// 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. + // Two results read from one table share a name; the later ones carry their occurrence so two never look alike. + static func displayLabel(at index: Int, in labels: [String]) -> String { + guard let occurrence = occurrence(at: index, in: labels) else { return labels[index] } + return String(format: String(localized: "%1$@ (%2$d)"), labels[index], occurrence) + } + + private static func occurrence(at index: Int, in labels: [String]) -> Int? { + let earlier = labels[.. 0 ? earlier + 1 : nil + } + + // A pinned result is pinned precisely so that nothing takes it away. func canClose(_ entry: ResultSetMenuEntry) -> Bool { !entry.isPinned } @@ -73,4 +84,13 @@ struct ResultSetMenuModel: Equatable { func canCompare(_ entry: ResultSetMenuEntry) -> Bool { entry.isComparable } + + // Counts Characters, so an emoji or a combining mark is never cut into a replacement character. + static func truncated(_ label: String, limit: Int) -> String { + guard label.count > limit else { return label } + return String(label.prefix(limit)).trimmingCharacters(in: .whitespaces) + "…" + } + + private static let compactTitleCharacterLimit = 16 + private static let tabTitleCharacterLimit = 24 } diff --git a/apple/TablePro/Models/Query/StatementAnchor.swift b/apple/TablePro/Models/Query/StatementAnchor.swift index ac67c32deb..d7e893029e 100644 --- a/apple/TablePro/Models/Query/StatementAnchor.swift +++ b/apple/TablePro/Models/Query/StatementAnchor.swift @@ -48,9 +48,7 @@ struct StatementAnchor: Equatable { let collapsed = source.split(whereSeparator: \.isWhitespace).joined(separator: " ") guard !collapsed.isEmpty else { return "" } - let text = collapsed as NSString - guard text.length > Self.labelLimit else { return collapsed } - return text.substring(to: Self.labelLimit).trimmingCharacters(in: .whitespaces) + "…" + return ResultSetMenuModel.truncated(collapsed, limit: Self.labelLimit) } private static let labelLimit = 28 diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index f9c6cadf13..c5a2ec7ca7 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -40543,6 +40543,9 @@ } } } + }, + "Clearing the results will discard all unsaved changes." : { + }, "CLI Connection Name" : { "extractionState" : "manual", @@ -42187,6 +42190,9 @@ } } } + }, + "Closing this result will discard all unsaved changes." : { + }, "Cloud" : { "extractionState" : "manual", @@ -133483,6 +133489,9 @@ } } } + }, + "Pinned" : { + }, "Pipe" : { "localizations" : { diff --git a/apple/TablePro/Views/Main/Child/MainEditorContentView.swift b/apple/TablePro/Views/Main/Child/MainEditorContentView.swift index e96674225f..1fdd1164aa 100644 --- a/apple/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/apple/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -526,19 +526,20 @@ struct MainEditorContentView: View { @ViewBuilder private func executionErrorBanner(tab: QueryTab) -> some View { - if let error = tab.display.activeResultSet?.errorMessage ?? tab.execution.errorMessage { + if let error = tab.shownError { InlineErrorBanner( - message: error, + message: error.message, onFixWithAI: coordinator.aiQueryActionAvailability(for: tab).isVisible - ? { coordinator.fixErrorWithAI(query: tab.execution.errorQuery ?? tab.content.query, error: error) } + ? { coordinator.fixErrorWithAI(query: error.query ?? tab.content.query, error: error.message) } : nil, - onDismiss: { - tabManager.mutate(tabId: tab.id) { - $0.display.activeResultSet?.errorMessage = nil - $0.execution.errorMessage = nil - $0.execution.errorQuery = nil + onDismiss: error.isDismissible + ? { + tabManager.mutate(tabId: tab.id) { + $0.execution.errorMessage = nil + $0.execution.errorQuery = nil + } } - } + : nil ) Divider() } @@ -606,6 +607,13 @@ struct MainEditorContentView: View { let presentation = resultPresentation(for: tab, rows: rows) VStack(spacing: 0) { + if presentation.resultSetChooser == .tabRow { + ResultSetTabBar( + model: resultSetMenuModel(for: tab, placedIn: .tabRow), + actions: resultSetActions(for: tab) + ) + } + if presentation.showsErrorBanner { executionErrorBanner(tab: tab) } @@ -938,28 +946,35 @@ struct MainEditorContentView: View { selectionSummary: coordinator.selectionSummary, isRefreshingSchema: schemaService.isRefreshing(connectionId: connectionId), viewMode: resultsViewModeBinding(for: tab), - resultSetMenu: resultSetMenuModel(for: tab), - onActivateResultSet: { coordinator.switchActiveResultSet(to: $0, in: tab.id) }, - onToggleResultSetPin: { coordinator.togglePinResultSet(id: $0) }, - onCompareResultSet: { coordinator.compareResultSet(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) - } - }, + resultSetMenu: resultSetMenuModel(for: tab, placedIn: .statusBar), + resultSetActions: resultSetActions(for: tab), onToggleFilters: { coordinator.toggleFilterPanel() }, onFetchAll: { coordinator.fetchAllRows() } ) } + private func resultSetActions(for tab: QueryTab) -> ResultSetActions { + let tabId = tab.id + return ResultSetActions( + activate: { coordinator.switchActiveResultSet(to: $0, in: tabId) }, + togglePin: { coordinator.togglePinResultSet(id: $0) }, + compare: { coordinator.compareResultSet(id: $0) }, + close: { coordinator.closeResultSet(id: $0) }, + closeOthers: { coordinator.closeOtherResultSets(keeping: $0) } + ) + } + // Only the three deciding fields, not a full `resultPresentation`, which would pull the whole row buffer for a row-free answer. - private func resultSetMenuModel(for tab: QueryTab) -> ResultSetMenuModel { + private func resultSetChooser(for tab: QueryTab) -> ResultSetChooserPlacement { 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 QueryResultPresentation(inputs: selectorInputs).resultSetChooser + } + + private func resultSetMenuModel(for tab: QueryTab, placedIn placement: ResultSetChooserPlacement) -> ResultSetMenuModel { + guard resultSetChooser(for: tab) == placement else { return ResultSetMenuModel(entries: [], activeOrdinal: 0, total: 0) } let activeId = tab.display.activeResultSet?.id diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+AIQueryActions.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+AIQueryActions.swift index c9998b929f..98aa44d8da 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+AIQueryActions.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+AIQueryActions.swift @@ -43,8 +43,8 @@ extension MainContentCoordinator { target: AssistantEditorTarget( tabId: tab.id, scope: scope(for: tab), - errorMessage: tab.display.activeResultSet?.errorMessage ?? tab.execution.errorMessage, - errorQuery: tab.execution.errorQuery + errorMessage: tab.latestError?.message, + errorQuery: tab.latestError?.query ) ) } diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Alerts.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Alerts.swift index b99c3504b0..b3aacae28f 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Alerts.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Alerts.swift @@ -43,6 +43,10 @@ extension MainContentCoordinator { return String(localized: "Applying or clearing filters will reload data and discard all unsaved changes.") case .resultSwitch: return String(localized: "Showing another result will discard all unsaved changes.") + case .resultClose: + return String(localized: "Closing this result will discard all unsaved changes.") + case .clearResults: + return String(localized: "Clearing the results will discard all unsaved changes.") /// Worded for both directions, because Forward reaches the same guard. Naming Back here /// misstated the operation the user was approving whenever they stepped forward. case .navigation: diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+ResultComparison.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+ResultComparison.swift index e147e7bbb7..0a683dbc8a 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+ResultComparison.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+ResultComparison.swift @@ -41,8 +41,10 @@ extension MainContentCoordinator { return position > 0 ? ids[position - 1] : nil } - static func comparisonTitle(label: String, ordinal: Int) -> String { - String(format: String(localized: "Result %1$d: %2$@"), ordinal, label) + // The chooser's own name for the result, so a counted name never sits beside a different count. + static func comparisonTitle(of resultSet: ResultSet, in display: TabDisplayState) -> String { + guard let index = display.resultSets.firstIndex(where: { $0.id == resultSet.id }) else { return resultSet.label } + return ResultSetMenuModel.displayLabel(at: index, in: display.resultSets.map(\.label)) } // Every result of the tab the comparison starts from, and every pinned result of any other query tab. @@ -53,13 +55,13 @@ extension MainContentCoordinator { for tab in coordinator.tabManager.tabs where tab.tabType == .query { let isHome = tab.id == homeTabId let tabId = tab.id - for (index, resultSet) in tab.display.resultSets.enumerated() + for resultSet in tab.display.resultSets where resultSet.id != leftId && (isHome || resultSet.isPinned) && coordinator.isComparable(resultSet, in: tab) { let resultId = resultSet.id let candidate = ResultComparisonCandidate( id: resultId, - title: comparisonTitle(label: resultSet.label, ordinal: index + 1), + title: comparisonTitle(of: resultSet, in: tab.display), group: isHome ? String(localized: "This Tab") : coordinator.comparisonDetail(for: tab), isPinned: resultSet.isPinned, read: { [weak coordinator] in @@ -87,7 +89,6 @@ extension MainContentCoordinator { guard !resultSet.isExplainResult else { return nil } let rows = comparisonRows(of: resultSet, in: tab) guard !rows.columns.isEmpty else { return nil } - let ordinal = (tab.display.resultSets.firstIndex { $0.id == resultSet.id } ?? 0) + 1 let primaryKey: [String] if let origin = resultSet.origin, origin.keysResolved, origin.tableName?.isEmpty == false { primaryKey = origin.primaryKeyColumns @@ -95,7 +96,7 @@ extension MainContentCoordinator { primaryKey = [] } return ResultComparisonSide( - title: Self.comparisonTitle(label: resultSet.label, ordinal: ordinal), + title: Self.comparisonTitle(of: resultSet, in: tab.display), detail: comparisonDetail(for: tab), columns: rows.columns, columnTypes: rows.columnTypes, diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift index ae3635b24c..7b37d1a066 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift @@ -26,28 +26,82 @@ extension MainContentCoordinator { tabManager.mutate(at: tabIdx) { $0.display.togglePin(resultSetId: id) } } + // Closing and stepping need what pinning needs: a query tab showing a result, not the structure editor. + var canCloseActiveResultSet: Bool { + guard canPinActiveResultSet, let active = tabManager.selectedTab?.display.activeResultSet else { return false } + return !active.isPinned + } + + func canStepActiveResultSet(by offset: Int) -> Bool { + guard canPinActiveResultSet, let tab = tabManager.selectedTab, + let activeId = tab.display.activeResultSet?.id, + let index = tab.display.resultSets.firstIndex(where: { $0.id == activeId }) else { return false } + return tab.display.resultSets.indices.contains(index + offset) + } + + // A table tab's one result is its rows; closing it would blank the table until a reload. func closeResultSet(id: UUID) { - guard let tabIdx = tabManager.selectedTabIndex else { return } - let rs = tabManager.tabs[tabIdx].display.resultSets.first { $0.id == id } - guard rs?.isPinned != true else { return } - let tabId = tabManager.tabs[tabIdx].id - tabManager.mutate(at: tabIdx) { $0.display.resultSets.removeAll { $0.id == id } } - if tabManager.tabs[tabIdx].display.activeResultSetId == id { - let newActiveId = tabManager.tabs[tabIdx].display.resultSets.last?.id - applyResultSetSwitch(to: newActiveId, in: tabId) + guard let (tab, tabIdx) = tabManager.selectedTabAndIndex, tab.tabType == .query, + tab.display.resultSets.contains(where: { $0.id == id && !$0.isPinned }) else { return } + removeResultSets([id], at: tabIdx) { display in + // The result that slides into the closed one's place, as with editor tabs. + guard let index = display.resultSets.firstIndex(where: { $0.id == id }) else { return display.activeResultSet?.id } + let remaining = display.resultSets.filter { $0.id != id } + return remaining.indices.contains(index) ? remaining[index].id : remaining.last?.id } - if tabManager.tabs[tabIdx].display.resultSets.isEmpty { - setActiveTableRows(TableRows(), for: tabId) + } + + // One removal rather than a close per result, so the active result is replaced once and by the one kept. + func closeOtherResultSets(keeping keptId: UUID) { + guard let (tab, tabIdx) = tabManager.selectedTabAndIndex, tab.tabType == .query, + tab.display.resultSets.contains(where: { $0.id == keptId }) else { return } + let closing = Set(tab.display.resultSets.filter { $0.id != keptId && !$0.isPinned }.map(\.id)) + guard !closing.isEmpty else { return } + removeResultSets(closing, at: tabIdx) { _ in keptId } + } + + private func removeResultSets( + _ ids: Set, + at tabIdx: Int, + replacement: @escaping (TabDisplayState) -> UUID? + ) { + let tabId = tabManager.tabs[tabIdx].id + let activeId = tabManager.tabs[tabIdx].display.activeResultSet?.id + guard let activeId, ids.contains(activeId) else { tabManager.mutate(at: tabIdx) { tab in - tab.execution.errorMessage = nil - tab.execution.rowsAffected = 0 - tab.execution.executionTime = nil - tab.execution.statusMessage = nil - tab.schemaVersion += 1 - tab.display.isResultsCollapsed = true + tab.display.resultSets.removeAll { ids.contains($0.id) && !$0.isPinned } + tab.display.activeResultSetId = activeId } - toolbarState.isResultsCollapsed = true + return + } + // The active result's unsaved edits leave with it, so this asks first, as switching does. + confirmDiscardChangesIfNeeded(action: .resultClose) { [weak self] confirmed in + guard confirmed, let self, + let tabIdx = self.tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } + let replacementId = replacement(self.tabManager.tabs[tabIdx].display) + self.tabManager.mutate(at: tabIdx) { tab in + tab.display.resultSets.removeAll { ids.contains($0.id) && !$0.isPinned } + } + if self.tabManager.tabs[tabIdx].display.resultSets.isEmpty { + self.clearEmptiedResults(at: tabIdx) + } else { + self.applyResultSetSwitch(to: replacementId, in: tabId) + } + } + } + + private func clearEmptiedResults(at tabIdx: Int) { + setActiveTableRows(TableRows(), for: tabManager.tabs[tabIdx].id) + tabManager.mutate(at: tabIdx) { tab in + tab.display.activeResultSetId = nil + tab.execution.errorMessage = nil + tab.execution.rowsAffected = 0 + tab.execution.executionTime = nil + tab.execution.statusMessage = nil + tab.schemaVersion += 1 + tab.display.isResultsCollapsed = true } + toolbarState.isResultsCollapsed = true } var canClearActiveQueryResults: Bool { @@ -55,17 +109,31 @@ extension MainContentCoordinator { return !tabSessionRegistry.tableRows(for: tab.id).rows.isEmpty || tab.execution.lastExecutedAt != nil } + // A pinned result on screen stays; any other goes with its unsaved edits, so that case asks first. func clearActiveQueryResults() { - guard let tabIdx = tabManager.selectedTabIndex else { return } - let tabId = tabManager.tabs[tabIdx].id + guard let tab = tabManager.selectedTab else { return } + let tabId = tab.id + guard tab.display.activeResultSet?.isPinned != true else { + clearUnpinnedResults(in: tabId) + return + } + confirmDiscardChangesIfNeeded(action: .clearResults) { [weak self] confirmed in + if confirmed { self?.clearUnpinnedResults(in: tabId) } + } + } - if let lastPinned = tabManager.tabs[tabIdx].display.resultSets.last(where: \.isPinned) { - applyResultSetSwitch(to: lastPinned.id, in: tabId) + private func clearUnpinnedResults(in tabId: UUID) { + guard let tabIdx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } + let display = tabManager.tabs[tabIdx].display + + if let lastPinned = display.resultSets.last(where: \.isPinned) { + let keepsActive = display.activeResultSet?.isPinned == true + if !keepsActive { + applyResultSetSwitch(to: lastPinned.id, in: tabId) + } 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.display.removeUnpinnedResults(keepingActive: keepsActive) + // A failed run's message sits on the tab, and would otherwise stay over the pinned result. tab.execution.errorMessage = nil tab.execution.errorQuery = nil } diff --git a/apple/TablePro/Views/Main/MainContentCommandActions.swift b/apple/TablePro/Views/Main/MainContentCommandActions.swift index 5a3f62be3f..7622d46b33 100644 --- a/apple/TablePro/Views/Main/MainContentCommandActions.swift +++ b/apple/TablePro/Views/Main/MainContentCommandActions.swift @@ -1231,23 +1231,27 @@ final class MainContentCommandActions: ObservableObject { } func previousResultTab() { - guard let coordinator, - let (tab, _) = coordinator.tabManager.selectedTabAndIndex else { return } - guard tab.display.resultSets.count > 1, - let currentId = tab.display.activeResultSetId ?? tab.display.resultSets.last?.id, - let currentIndex = tab.display.resultSets.firstIndex(where: { $0.id == currentId }), - currentIndex > 0 else { return } - coordinator.switchActiveResultSet(to: tab.display.resultSets[currentIndex - 1].id, in: tab.id) + stepResultTab(by: -1) } func nextResultTab() { - guard let coordinator, - let (tab, _) = coordinator.tabManager.selectedTabAndIndex else { return } - guard tab.display.resultSets.count > 1, - let currentId = tab.display.activeResultSetId ?? tab.display.resultSets.last?.id, - let currentIndex = tab.display.resultSets.firstIndex(where: { $0.id == currentId }), - currentIndex < tab.display.resultSets.count - 1 else { return } - coordinator.switchActiveResultSet(to: tab.display.resultSets[currentIndex + 1].id, in: tab.id) + stepResultTab(by: 1) + } + + var canShowPreviousResult: Bool { + coordinator?.canStepActiveResultSet(by: -1) ?? false + } + + var canShowNextResult: Bool { + coordinator?.canStepActiveResultSet(by: 1) ?? false + } + + private func stepResultTab(by offset: Int) { + guard let coordinator, coordinator.canStepActiveResultSet(by: offset), + let (tab, _) = coordinator.tabManager.selectedTabAndIndex, + let activeId = tab.display.activeResultSet?.id, + let index = tab.display.resultSets.firstIndex(where: { $0.id == activeId }) else { return } + coordinator.switchActiveResultSet(to: tab.display.resultSets[index + offset].id, in: tab.id) } var canPinResultTab: Bool { @@ -1272,10 +1276,12 @@ final class MainContentCommandActions: ObservableObject { coordinator?.compareActiveResult() } + var canCloseResultTab: Bool { + coordinator?.canCloseActiveResultSet ?? false + } + func closeResultTab() { - guard let coordinator else { return } - let tab = coordinator.tabManager.selectedTab - guard let activeId = tab?.display.activeResultSetId ?? tab?.display.resultSets.last?.id else { return } + guard let coordinator, let activeId = coordinator.tabManager.selectedTab?.display.activeResultSet?.id else { return } coordinator.closeResultSet(id: activeId) } diff --git a/apple/TablePro/Views/Main/MainContentCoordinator.swift b/apple/TablePro/Views/Main/MainContentCoordinator.swift index 1e4fd80455..4677384b43 100644 --- a/apple/TablePro/Views/Main/MainContentCoordinator.swift +++ b/apple/TablePro/Views/Main/MainContentCoordinator.swift @@ -20,6 +20,8 @@ enum DiscardAction { case pagination case filter case resultSwitch + case resultClose + case clearResults case navigation case displayOrder case columnVisibility diff --git a/apple/TablePro/Views/Results/InlineErrorBanner.swift b/apple/TablePro/Views/Results/InlineErrorBanner.swift index 510ea60822..c688976a06 100644 --- a/apple/TablePro/Views/Results/InlineErrorBanner.swift +++ b/apple/TablePro/Views/Results/InlineErrorBanner.swift @@ -14,9 +14,11 @@ struct InlineErrorBanner: View { var onDismiss: (() -> Void)? private let maxMessageHeight: CGFloat = 96 + private let minMessageHeight: CGFloat = 16 @State private var messageHeight: CGFloat = 0 + @State private var shownHeight: CGFloat = 0 - private var messageFits: Bool { messageHeight <= maxMessageHeight } + private var messageFits: Bool { messageHeight <= shownHeight + 0.5 } var body: some View { HStack(alignment: .top, spacing: 8) { @@ -34,7 +36,16 @@ struct InlineErrorBanner: View { messageHeight = height } } - .frame(height: min(messageHeight, maxMessageHeight)) + // Gives way down to one line in a short pane, so the bars above and below it stay inside the pane. + .frame( + minHeight: min(messageHeight, minMessageHeight), + maxHeight: min(messageHeight, maxMessageHeight) + ) + .onGeometryChange(for: CGFloat.self) { proxy in + proxy.size.height + } action: { height in + shownHeight = height + } .scrollDisabled(messageFits) .scrollBounceBasedOnSize() if let onFixWithAI { diff --git a/apple/TablePro/Views/Results/ResultSetMenu.swift b/apple/TablePro/Views/Results/ResultSetMenu.swift index 99b74fa10c..a14e2445d1 100644 --- a/apple/TablePro/Views/Results/ResultSetMenu.swift +++ b/apple/TablePro/Views/Results/ResultSetMenu.swift @@ -5,27 +5,32 @@ import SwiftUI -/// A pull-down in the status bar rather than a strip of its own, which spent 32pt in every query tab, even with one result. +struct ResultSetActions { + let activate: (UUID) -> Void + let togglePin: (UUID) -> Void + let compare: (UUID) -> Void + let close: (UUID) -> Void + let closeOthers: (UUID) -> Void + + static var none: ResultSetActions { + ResultSetActions(activate: { _ in }, togglePin: { _ in }, compare: { _ in }, close: { _ in }, closeOthers: { _ in }) + } +} + +// Lists every result and acts on the current one. A tab row cannot do the second: no stock control tells which segment was right-clicked. 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 onCompare: (UUID) -> Void - let onClose: (UUID) -> Void - let onCloseOthers: (UUID) -> Void + let actions: ResultSetActions var body: some View { Menu { ForEach(model.entries) { entry in Button { - onActivate(entry.id) + actions.activate(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) + Text(model.displayLabel(of: entry)) } icon: { if entry.isActive { Image(systemName: "checkmark") @@ -43,16 +48,16 @@ struct ResultSetMenu: View { ? String(localized: "Unpin Result") : String(localized: "Pin Result") ) { - onTogglePin(active.id) + actions.togglePin(active.id) } - Button(String(localized: "Compare Result With…")) { onCompare(active.id) } + Button(String(localized: "Compare Result With…")) { actions.compare(active.id) } .disabled(!model.canCompare(active)) - Button(String(localized: "Close Result")) { onClose(active.id) } + Button(String(localized: "Close Result")) { actions.close(active.id) } .disabled(!model.canClose(active)) - Button(String(localized: "Close Other Results")) { onCloseOthers(active.id) } + Button(String(localized: "Close Other Results")) { actions.closeOthers(active.id) } .disabled(!model.canCloseOthers(active)) } } label: { diff --git a/apple/TablePro/Views/Results/ResultSetTabBar.swift b/apple/TablePro/Views/Results/ResultSetTabBar.swift new file mode 100644 index 0000000000..7950eb4b07 --- /dev/null +++ b/apple/TablePro/Views/Results/ResultSetTabBar.swift @@ -0,0 +1,119 @@ +// +// ResultSetTabBar.swift +// TablePro +// + +import AppKit +import SwiftUI + +// Two or more results: a tab per result for one-click switching, and the pull-down that lists them all and acts on the current one. +struct ResultSetTabBar: View { + let model: ResultSetMenuModel + let actions: ResultSetActions + + var body: some View { + VStack(spacing: 0) { + ViewThatFits(in: .horizontal) { + if model.offersTabs { + row(showsTabs: true, isSpelledOut: true) + row(showsTabs: true, isSpelledOut: false) + } + row(showsTabs: false, isSpelledOut: true) + row(showsTabs: false, isSpelledOut: false) + } + .padding(.horizontal, StatusBarChrome.horizontalPadding) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: StatusBarChrome.height) + .paneBarBackground() + Divider() + } + } + + private func row(showsTabs: Bool, isSpelledOut: Bool) -> some View { + HStack(spacing: StatusBarChrome.clusterSpacing) { + if showsTabs { + // SwiftUI sets a hosted control's size from its environment, so it is set here and not in the control. + ResultSetTabs(model: model, onActivate: actions.activate) + .controlSize(.small) + } + Spacer(minLength: 0) + ResultSetMenu(model: model, isSpelledOut: isSpelledOut, actions: actions) + } + } +} + +struct ResultSetTabs: NSViewRepresentable { + let model: ResultSetMenuModel + let onActivate: (UUID) -> Void + + func makeNSView(context: Context) -> ResultSetTabsControl { + ResultSetTabsControl() + } + + func updateNSView(_ control: ResultSetTabsControl, context: Context) { + control.onActivate = onActivate + control.apply(model) + } + + // Always the width every tab needs, so `ViewThatFits` gives up the tabs rather than squeezing them. + func sizeThatFits(_ proposal: ProposedViewSize, nsView control: ResultSetTabsControl, context: Context) -> CGSize? { + control.intrinsicContentSize + } +} + +// AppKit on purpose: a SwiftUI segmented or `.tabs` picker drops a segment's image, and the pin is what a tab must show (measured on macOS 27). +@MainActor +final class ResultSetTabsControl: NSSegmentedControl { + var onActivate: ((UUID) -> Void)? + private var resultIds: [UUID] = [] + private var activeIndex = -1 + + init() { + super.init(frame: .zero) + trackingMode = .selectOne + #if SDK_MACOS_27 + if #available(macOS 27.0, *) { + role = .tabs + } + #endif + target = self + action = #selector(segmentChosen(_:)) + setAccessibilityLabel(String(localized: "Results")) + setAccessibilityIdentifier("result-set-tabs") + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("ResultSetTabsControl does not support NSCoder init") + } + + func apply(_ model: ResultSetMenuModel) { + if segmentCount != model.entries.count { + segmentCount = model.entries.count + } + for (index, entry) in model.entries.enumerated() { + let title = model.tabTitle(of: entry) + if label(forSegment: index) != title { + setLabel(title, forSegment: index) + } + setImage(entry.isPinned ? Self.pinImage : nil, forSegment: index) + setToolTip(model.displayLabel(of: entry), forSegment: index) + } + resultIds = model.entries.map(\.id) + activeIndex = model.entries.firstIndex(where: \.isActive) ?? -1 + selectedSegment = activeIndex + } + + @objc private func segmentChosen(_ sender: NSSegmentedControl) { + let chosen = selectedSegment + // The model decides which tab is lit, so a switch the discard prompt cancels leaves the current one selected. + selectedSegment = activeIndex + guard resultIds.indices.contains(chosen), chosen != activeIndex else { return } + onActivate?(resultIds[chosen]) + } + + private static let pinImage = NSImage( + systemSymbolName: "pin.fill", + accessibilityDescription: String(localized: "Pinned") + )?.withSymbolConfiguration(NSImage.SymbolConfiguration(scale: .small)) +} diff --git a/apple/TablePro/Views/Results/ResultStatusBar.swift b/apple/TablePro/Views/Results/ResultStatusBar.swift index 170346bdc0..46496d04f7 100644 --- a/apple/TablePro/Views/Results/ResultStatusBar.swift +++ b/apple/TablePro/Views/Results/ResultStatusBar.swift @@ -45,12 +45,9 @@ 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 + // Empty unless the tab holds exactly one result: two or more are chosen from the tab row above the result. let resultSetMenu: ResultSetMenuModel - let onActivateResultSet: (UUID) -> Void - let onToggleResultSetPin: (UUID) -> Void - let onCompareResultSet: (UUID) -> Void - let onCloseResultSet: (UUID) -> Void - let onCloseOtherResultSets: (UUID) -> Void + let resultSetActions: ResultSetActions let onToggleFilters: () -> Void let onFetchAll: (() -> Void)? @@ -111,11 +108,7 @@ struct ResultStatusBar: View { ResultSetMenu( model: resultSetMenu, isSpelledOut: presentation.resultSetMenuIsSpelledOut, - onActivate: onActivateResultSet, - onTogglePin: onToggleResultSetPin, - onCompare: onCompareResultSet, - onClose: onCloseResultSet, - onCloseOthers: onCloseOtherResultSets + actions: resultSetActions ) } if model.controls.showsReadout || model.controls.showsExecution { diff --git a/apple/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift b/apple/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift index 2856e120f5..ad9c23b09f 100644 --- a/apple/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift +++ b/apple/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift @@ -127,8 +127,12 @@ final class MockDatabaseDriver: DatabaseDriver, SchemaSwitchable, @unchecked Sen applyQueryTimeoutValues.append(seconds) } + // A statement holding this text fails, the way a server rejects one statement of a script. + var failsStatementContaining: (text: String, error: any Error)? + func execute(query: String) async throws -> QueryResult { executedQueries.append(query) + if let failure = failsStatementContaining, query.contains(failure.text) { throw failure.error } if executeDelaySeconds > 0 { try await Task.sleep(nanoseconds: UInt64(executeDelaySeconds * 1_000_000_000)) } @@ -179,6 +183,7 @@ final class MockDatabaseDriver: DatabaseDriver, SchemaSwitchable, @unchecked Sen } func executeUserQuery(query: String, rowCap: Int?, parameters: [Any?]?) async throws -> QueryResult { + if let failure = failsStatementContaining, query.contains(failure.text) { throw failure.error } if pausesNextUserQuery { pausesNextUserQuery = false await withCheckedContinuation { continuation in diff --git a/apple/TableProTests/Models/Query/QueryResultPresentationTests.swift b/apple/TableProTests/Models/Query/QueryResultPresentationTests.swift index 339f6a15d2..ee978180f7 100644 --- a/apple/TableProTests/Models/Query/QueryResultPresentationTests.swift +++ b/apple/TableProTests/Models/Query/QueryResultPresentationTests.swift @@ -297,6 +297,81 @@ struct QueryResultPresentationTests { #expect(QueryResultPresentation(inputs: inputs).showsResultSetSelector == false) } + @Test("One result is chosen from the bar, two or more from a tab row") + func chooserPlacementFollowsTheCount() { + var inputs = QueryResultInputs() + let placements = (0 ... 8).map { count -> ResultSetChooserPlacement in + inputs.resultSetCount = count + return QueryResultPresentation(inputs: inputs).resultSetChooser + } + + #expect(placements == [.hidden, .statusBar, .tabRow, .tabRow, .tabRow, .tabRow, .tabRow, .tabRow, .tabRow]) + } + + @Test("A table tab and the structure editor place no chooser whatever the count") + func chooserPlacementNeedsAQueryResult() { + var table = QueryResultInputs() + table.tabType = .table + table.resultSetCount = 3 + #expect(QueryResultPresentation(inputs: table).resultSetChooser == .hidden) + + var structure = QueryResultInputs() + structure.viewMode = .structure + structure.tableName = "orders" + structure.resultSetCount = 3 + #expect(QueryResultPresentation(inputs: structure).resultSetChooser == .hidden) + } + + // Dismissing the Error result's own message used to turn it into "Query executed successfully". + @Test("An Error result's banner cannot be dismissed, the tab's own message can") + @MainActor + func onlyTheTabsMessageIsDismissible() throws { + let failed = ResultSet(label: "Error") + failed.errorMessage = "relation \"nope\" does not exist" + failed.failedQuery = "SELECT * FROM nope" + var tab = QueryTab(title: "Query", query: "SELECT 1;\nSELECT * FROM nope;", tabType: .query) + tab.display.resultSets = [ResultSet(label: "Result 1"), failed] + tab.display.activeResultSetId = failed.id + + let resultError = try #require(tab.shownError) + #expect(resultError.message == "relation \"nope\" does not exist") + #expect(resultError.query == "SELECT * FROM nope") + #expect(resultError.isDismissible == false) + + tab.execution.errorMessage = "syntax error" + tab.execution.errorQuery = "SELEC 2" + let newer = try #require(tab.shownError) + #expect(newer.message == "syntax error") + #expect(newer.query == "SELEC 2") + #expect(newer.isDismissible) + } + + @Test("A sibling of an Error result shows no banner, and Fix with AI still finds the failure") + @MainActor + func siblingOfAnErrorResultHasNoBanner() throws { + let first = ResultSet(label: "Result 1") + let failed = ResultSet(label: "Error") + failed.errorMessage = "relation \"nope\" does not exist" + failed.failedQuery = "SELECT * FROM nope" + var tab = QueryTab(title: "Query", query: "SELECT 1;\nSELECT * FROM nope;", tabType: .query) + tab.display.resultSets = [first, failed] + tab.display.activeResultSetId = first.id + + #expect(tab.shownError == nil) + #expect(try #require(tab.latestError).query == "SELECT * FROM nope") + } + + @Test("The tab's newer message shows ahead of the active result's own") + func tabMessageShowsFirst() { + var inputs = QueryResultInputs() + inputs.hasExecuted = true + inputs.hasActiveResultSet = true + inputs.activeResultErrorMessage = "older" + inputs.executionErrorMessage = "newer" + + #expect(QueryResultPresentation.resolvedError(inputs) == "newer") + } + @Test("Filter chrome follows the mode that can be filtered") func filterChromeFollowsMode() { var inputs = QueryResultInputs() diff --git a/apple/TableProTests/Models/Query/ResultSetMenuModelTests.swift b/apple/TableProTests/Models/Query/ResultSetMenuModelTests.swift index 00192fe223..105d0d2c51 100644 --- a/apple/TableProTests/Models/Query/ResultSetMenuModelTests.swift +++ b/apple/TableProTests/Models/Query/ResultSetMenuModelTests.swift @@ -8,13 +8,12 @@ import Foundation import Testing 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. + // A position rather than a name: "Result 2 of 4" read as the name of a result counted "Result 3" after a close. @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") + #expect(model.title == "2 of 4") } @Test("A single result is named rather than counted") @@ -81,6 +80,54 @@ struct ResultSetMenuModelTests { #expect(model.activeEntry == nil) } + @Test("Tabs are offered from two results up to six, the HIG's limit for a tab view") + func tabsNeedTwoToSixResults() { + #expect(Self.make(count: 1, activeIndex: 0).offersTabs == false) + #expect(Self.make(count: 2, activeIndex: 0).offersTabs) + #expect(Self.make(count: 6, activeIndex: 0).offersTabs) + #expect(Self.make(count: 7, activeIndex: 0).offersTabs == false) + } + + @Test("Two results from one table get names that tell them apart") + func duplicateLabelsAreNumbered() { + let model = Self.make(labels: ["users", "orders", "users", "users"], activeIndex: 0) + + #expect(model.entries.map(model.displayLabel(of:)) == ["users", "orders", "users (2)", "users (3)"]) + } + + @Test("A long name is cut on a whole character, never inside an emoji") + func truncationKeepsWholeCharacters() { + let label = "Monthly revenue per region 📈 for Q3" + let cut = ResultSetMenuModel.truncated(label, limit: 28) + + #expect(cut == "Monthly revenue per region 📈…") + #expect(cut.unicodeScalars.contains("\u{FFFD}") == false) + #expect(ResultSetMenuModel.truncated("orders", limit: 28) == "orders") + } + + @Test("A tab's title is the disambiguated name, cut to fit") + func tabTitleIsBounded() throws { + let model = Self.make(labels: ["a_table_name_long_enough_to_need_cutting"], activeIndex: 0) + let entry = try #require(model.entries.first) + + #expect(model.tabTitle(of: entry).count <= 25) + #expect(model.tabTitle(of: entry).hasSuffix("…")) + } + + private static func make(labels: [String], activeIndex: Int) -> ResultSetMenuModel { + let entries = labels.enumerated().map { index, label in + ResultSetMenuEntry( + id: UUID(), + label: label, + isPinned: false, + isActive: index == activeIndex, + ordinal: index + 1, + isComparable: true + ) + } + return ResultSetMenuModel(entries: entries, activeOrdinal: activeIndex + 1, total: labels.count) + } + private static func make( count: Int, activeIndex: Int, diff --git a/apple/TableProTests/Models/Query/ResultSetPolicyTests.swift b/apple/TableProTests/Models/Query/ResultSetPolicyTests.swift index e965e67729..d195e0a28d 100644 --- a/apple/TableProTests/Models/Query/ResultSetPolicyTests.swift +++ b/apple/TableProTests/Models/Query/ResultSetPolicyTests.swift @@ -99,6 +99,13 @@ struct ResultSetPolicyTests { display.resultsViewMode = mode states.append(display) + for count in 2 ... 8 { + var several = display + several.resultSets = (1 ... count).map { ResultSet(label: "Result \($0)") } + several.activeResultSetId = several.resultSets.first?.id + states.append(several) + } + var withoutResults = display withoutResults.resultSets = [] withoutResults.activeResultSetId = nil diff --git a/apple/TableProTests/Models/Query/StatementAnchorTests.swift b/apple/TableProTests/Models/Query/StatementAnchorTests.swift index 5cb22c377e..a8e39607ad 100644 --- a/apple/TableProTests/Models/Query/StatementAnchorTests.swift +++ b/apple/TableProTests/Models/Query/StatementAnchorTests.swift @@ -118,6 +118,16 @@ struct StatementAnchorTests { #expect(subject.label.count <= 29) } + @Test("A comment cut at the limit keeps an emoji whole") + func labelIsCutOnWholeCharacters() { + let subject = StatementAnchor( + range: NSRange(location: 0, length: 8), + preview: "-- Monthly revenue per region 📈 for Q3\nSELECT 1" + ) + #expect(subject.label == "Monthly revenue per region 📈…") + #expect(subject.label.unicodeScalars.contains("\u{FFFD}") == false) + } + // MARK: - What a result is called @Test("The table wins when there is one") diff --git a/apple/TableProTests/Views/Main/ResultPinningTests.swift b/apple/TableProTests/Views/Main/ResultPinningTests.swift index 05249d6bed..2d0552c968 100644 --- a/apple/TableProTests/Views/Main/ResultPinningTests.swift +++ b/apple/TableProTests/Views/Main/ResultPinningTests.swift @@ -141,6 +141,156 @@ struct ResultPinningTests { #expect(coordinator.tabManager.selectedTab?.display.resultSets.map(\.id) == [pinned.id]) } + @Test("Closing the active result selects the one that takes its place") + @MainActor + func closeSelectsTheNeighbour() throws { + let coordinator = Self.makeCoordinator() + defer { coordinator.teardown() } + let results = try Self.seed(coordinator, labels: ["a", "b", "c", "d"], activeIndex: 1) + + coordinator.closeResultSet(id: results[1].id) + + let display = try #require(coordinator.tabManager.selectedTab?.display) + #expect(display.resultSets.map(\.id) == [results[0].id, results[2].id, results[3].id]) + #expect(display.activeResultSet?.id == results[2].id) + } + + @Test("Closing the last result selects the one before it") + @MainActor + func closeLastSelectsThePrevious() throws { + let coordinator = Self.makeCoordinator() + defer { coordinator.teardown() } + let results = try Self.seed(coordinator, labels: ["a", "b", "c"], activeIndex: 2) + + coordinator.closeResultSet(id: results[2].id) + + #expect(coordinator.tabManager.selectedTab?.display.activeResultSet?.id == results[1].id) + } + + @Test("Closing a result that is not shown keeps the one that is") + @MainActor + func closeBackgroundResultKeepsActive() throws { + let coordinator = Self.makeCoordinator() + defer { coordinator.teardown() } + let results = try Self.seed(coordinator, labels: ["a", "b", "c"], activeIndex: 0) + + coordinator.closeResultSet(id: results[2].id) + + let display = try #require(coordinator.tabManager.selectedTab?.display) + #expect(display.resultSets.map(\.id) == [results[0].id, results[1].id]) + #expect(display.activeResultSet?.id == results[0].id) + } + + @Test("Close Others keeps the pinned results and shows the one it kept") + @MainActor + func closeOthersShowsTheKeptResult() throws { + let coordinator = Self.makeCoordinator() + defer { coordinator.teardown() } + let results = try Self.seed(coordinator, labels: ["a", "b", "c", "d"], activeIndex: 3, pinnedIndices: [0]) + + coordinator.closeOtherResultSets(keeping: results[1].id) + + let display = try #require(coordinator.tabManager.selectedTab?.display) + #expect(display.resultSets.map(\.id) == [results[0].id, results[1].id]) + #expect(display.activeResultSet?.id == results[1].id) + } + + @Test("Closing the active result waits for the answer about its unsaved edits") + @MainActor + func closeAsksBeforeDroppingEdits() throws { + let coordinator = Self.makeCoordinator() + defer { coordinator.teardown() } + let results = try Self.seed(coordinator, labels: ["a", "b"], activeIndex: 1) + Self.stageEdit(on: coordinator) + coordinator.isShowingConfirmAlert = true + + coordinator.closeResultSet(id: results[1].id) + coordinator.closeOtherResultSets(keeping: results[0].id) + + let display = try #require(coordinator.tabManager.selectedTab?.display) + #expect(display.resultSets.map(\.id) == [results[0].id, results[1].id]) + #expect(coordinator.changeManager.hasChanges) + } + + @Test("A table tab's rows cannot be closed as a result") + @MainActor + func tableTabRefusesClose() throws { + let coordinator = Self.makeCoordinator() + defer { coordinator.teardown() } + try coordinator.tabManager.addTableTab(tableName: "users") + let index = try #require(coordinator.tabManager.selectedTabIndex) + let rows = Self.makeResultSet(label: "users") + coordinator.tabManager.mutate(at: index) { tab in + tab.display.resultSets = [rows] + tab.display.activeResultSetId = rows.id + } + + #expect(coordinator.canCloseActiveResultSet == false) + #expect(coordinator.canStepActiveResultSet(by: 1) == false) + coordinator.closeResultSet(id: rows.id) + + #expect(coordinator.tabManager.selectedTab?.display.resultSets.map(\.id) == [rows.id]) + } + + @Test("The View menu offers only the result steps and close that would do something") + @MainActor + func resultCommandsMatchWhatTheyWouldDo() throws { + let coordinator = Self.makeCoordinator() + defer { coordinator.teardown() } + _ = try Self.seed(coordinator, labels: ["a", "b", "c"], activeIndex: 0, pinnedIndices: [0]) + + #expect(coordinator.canStepActiveResultSet(by: -1) == false) + #expect(coordinator.canStepActiveResultSet(by: 1)) + #expect(coordinator.canCloseActiveResultSet == false) + + let index = try #require(coordinator.tabManager.selectedTabIndex) + let last = try #require(coordinator.tabManager.selectedTab?.display.resultSets.last) + coordinator.tabManager.mutate(at: index) { $0.display.activeResultSetId = last.id } + + #expect(coordinator.canStepActiveResultSet(by: -1)) + #expect(coordinator.canStepActiveResultSet(by: 1) == false) + #expect(coordinator.canCloseActiveResultSet) + } + + @MainActor + private static func seed( + _ coordinator: MainContentCoordinator, + labels: [String], + activeIndex: Int, + pinnedIndices: Set = [] + ) throws -> [ResultSet] { + coordinator.tabManager.addTab(databaseName: "db") + let index = try #require(coordinator.tabManager.selectedTabIndex) + let results = labels.enumerated().map { offset, label in + makeResultSet(label: label, isPinned: pinnedIndices.contains(offset)) + } + coordinator.tabManager.mutate(at: index) { tab in + tab.display.resultSets = results + tab.display.activeResultSetId = results[activeIndex].id + } + return results + } + + @MainActor + private static func stageEdit(on coordinator: MainContentCoordinator) { + coordinator.changeManager.configureForTable( + tableName: "users", + columns: ["id", "name"], + primaryKeyColumns: ["id"], + databaseType: .mysql, + generatedColumns: [], + triggerReload: false + ) + coordinator.changeManager.recordCellChange( + rowID: .existing(0), + columnIndex: 1, + columnName: "name", + oldValue: "Alice", + newValue: "Bob", + originalRow: ["1", "Alice"] + ) + } + @Test("Toggling pin flips the flag on the addressed result") @MainActor func togglePinFlipsFlag() throws { diff --git a/apple/TableProTests/Views/Main/ResultStatusBarLayoutTests.swift b/apple/TableProTests/Views/Main/ResultStatusBarLayoutTests.swift index e043f49c1f..6afd854e6a 100644 --- a/apple/TableProTests/Views/Main/ResultStatusBarLayoutTests.swift +++ b/apple/TableProTests/Views/Main/ResultStatusBarLayoutTests.swift @@ -90,11 +90,7 @@ struct ResultStatusBarLayoutTests { isRefreshingSchema: false, viewMode: .constant(viewMode), resultSetMenu: ResultSetMenuModel(entries: [], activeOrdinal: 0, total: 0), - onActivateResultSet: { _ in }, - onToggleResultSetPin: { _ in }, - onCompareResultSet: { _ in }, - onCloseResultSet: { _ in }, - onCloseOtherResultSets: { _ in }, + resultSetActions: .none, onToggleFilters: {}, onFetchAll: {} ) @@ -351,11 +347,7 @@ struct ResultStatusBarLayoutTests { isRefreshingSchema: false, viewMode: .constant(.data), resultSetMenu: ResultSetMenuModel(entries: [], activeOrdinal: 0, total: 0), - onActivateResultSet: { _ in }, - onToggleResultSetPin: { _ in }, - onCompareResultSet: { _ in }, - onCloseResultSet: { _ in }, - onCloseOtherResultSets: { _ in }, + resultSetActions: .none, onToggleFilters: {}, onFetchAll: {} ) diff --git a/apple/TableProTests/Views/Main/ScriptErrorResultTests.swift b/apple/TableProTests/Views/Main/ScriptErrorResultTests.swift new file mode 100644 index 0000000000..9479a92e54 --- /dev/null +++ b/apple/TableProTests/Views/Main/ScriptErrorResultTests.swift @@ -0,0 +1,57 @@ +import Foundation +import TableProTestSupport +import Testing + +@testable import TablePro + +@MainActor +struct ScriptErrorResultTests { + // The tab used to keep a copy of the message, which then sat over every other result of the run. + @Test("A failed script keeps its error on the Error result, not over the results that succeeded") + func failureStaysOnTheErrorResult() async throws { + let connection = TestFixtures.makeConnection() + let driver = MockDatabaseDriver(connection: connection) + driver.failsStatementContaining = ( + "nope", + NSError(domain: "test", code: 1, userInfo: [NSLocalizedDescriptionKey: "relation \"nope\" does not exist"]) + ) + var session = ConnectionSession(connection: connection, driver: driver) + session.status = .connected + session.browseDatabase = connection.database + DatabaseManager.shared.injectSession(session, for: connection.id) + let tabManager = QueryTabManager() + let coordinator = MainContentCoordinator( + connection: connection, + tabManager: tabManager, + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + defer { + coordinator.cancelAllQueryTasks() + coordinator.teardown() + DatabaseManager.shared.removeSession(for: connection.id) + } + let tab = QueryTab(title: "Query", query: "SELECT 1;\nSELECT * FROM nope;", tabType: .query) + tabManager.tabs.append(tab) + tabManager.selectedTabId = tab.id + + coordinator.runAllStatements() + try await waitUntil { + !coordinator.tabExecution.isExecuting(tab.id) + && tabManager.tabs.first?.display.resultSets.last?.errorMessage != nil + } + + let settled = try #require(tabManager.tabs.first) + let failed = try #require(settled.display.resultSets.last) + #expect(settled.display.resultSets.count == 2) + #expect(settled.execution.errorMessage == nil) + #expect(failed.failedQuery?.contains("nope") == true) + #expect(settled.shownError?.isDismissible == false) + + let first = try #require(settled.display.resultSets.first) + tabManager.mutate(tabId: tab.id) { $0.display.activeResultSetId = first.id } + let sibling = try #require(tabManager.tabs.first) + #expect(sibling.shownError == nil) + #expect(sibling.latestError?.message.contains("nope") == true) + } +} diff --git a/apple/TableProTests/Views/Results/ResultSetTabBarLayoutTests.swift b/apple/TableProTests/Views/Results/ResultSetTabBarLayoutTests.swift new file mode 100644 index 0000000000..e7c9715950 --- /dev/null +++ b/apple/TableProTests/Views/Results/ResultSetTabBarLayoutTests.swift @@ -0,0 +1,140 @@ +import AppKit +import Foundation +import SwiftUI +import Testing + +@testable import TablePro + +private let tabBarHostWidths: [CGFloat] = [1_400, 900, 600, 400, 320] + +@MainActor +struct ResultSetTabBarLayoutTests { + @Test("Tabs show when every name fits the pane") + func tabsShowWhenTheyFit() { + let host = host(ResultSetTabBar(model: Self.model(labels: ["users", "orders", "Result 3"]), actions: .none), width: 900) + + #expect(tabControls(in: host).count == 1) + #expect(tabControls(in: host).first?.segmentCount == 3) + } + + @Test("A pane too narrow for the tabs keeps only the pull-down") + func narrowPaneFallsBackToThePullDown() { + let labels = (1 ... 6).map { "monthly_revenue_by_region_\($0)" } + let host = host(ResultSetTabBar(model: Self.model(labels: labels), actions: .none), width: 400) + + #expect(tabControls(in: host).isEmpty) + } + + @Test("More than six results are listed by the pull-down alone") + func moreThanSixHaveNoTabs() { + let labels = (1 ... 7).map { "r\($0)" } + let host = host(ResultSetTabBar(model: Self.model(labels: labels), actions: .none), width: 1_400) + + #expect(tabControls(in: host).isEmpty) + } + + @Test("A pinned result's tab carries the pin and selection follows the active result") + func tabsMirrorTheModel() throws { + let host = host( + ResultSetTabBar(model: Self.model(labels: ["users", "orders"], activeIndex: 1, pinnedIndices: [0]), actions: .none), + width: 900 + ) + let tabs = try #require(tabControls(in: host).first) + + #expect(tabs.image(forSegment: 0) != nil) + #expect(tabs.image(forSegment: 1) == nil) + #expect(tabs.selectedSegment == 1) + #expect(tabs.label(forSegment: 0) == "users") + } + + @Test("The tabs never reach past the pane", arguments: tabBarHostWidths) + func tabsStayInsideThePane(width: CGFloat) { + let labels = ["users", "orders", "invoice_line_items", "Result 4"] + let host = host(ResultSetTabBar(model: Self.model(labels: labels), actions: .none), width: width) + + for tabs in tabControls(in: host) { + let frame = tabs.convert(tabs.bounds, to: host.view) + #expect(frame.minX >= 0 && frame.maxX <= width, "tabs at \(frame) in a \(width)pt pane") + } + } + + // The results pane can be dragged down to 150pt; with a long error the banner gives way, never the bars. + @Test("At the pane's minimum height the tab row and the status bar both stay inside it") + func shortPaneKeepsBothBars() throws { + let box = BarFrames() + let message = Array(repeating: "ERROR: relation \"nope\" does not exist at character 15", count: 8) + .joined(separator: "\n") + let stack = VStack(spacing: 0) { + ResultSetTabBar(model: Self.model(labels: ["Result 1", "Error"]), actions: .none) + .onGeometryChange(for: CGRect.self) { $0.frame(in: .named(Self.pane)) } action: { box.top = $0 } + InlineErrorBanner(message: message) + Color.clear.frame(maxHeight: .infinity) + Color.clear + .frame(height: StatusBarChrome.height + 1) + .onGeometryChange(for: CGRect.self) { $0.frame(in: .named(Self.pane)) } action: { box.bottom = $0 } + } + let height = CollapsibleSplitController.defaultBottomMinimumThickness + // An oversized stack is centred in its frame, so an overflow shows as a bar outside the pane's bounds. + let pane = stack + .frame(width: 600, height: height) + .coordinateSpace(name: Self.pane) + _ = host(pane, width: 600, height: height) + + let top = try #require(box.top) + let bottom = try #require(box.bottom) + #expect(top.minY >= 0, "the tab row starts at \(top.minY)") + #expect(bottom.maxY <= height, "the status bar ends at \(bottom.maxY) in a \(height)pt pane") + } + + private static let pane = "pane" + + private func host(_ content: some View, width: CGFloat, height: CGFloat = 40) -> HostedView { + let host = NSHostingView(rootView: AnyView(content)) + host.sizingOptions = [] + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: width, height: height), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.contentView = host + host.frame = NSRect(x: 0, y: 0, width: width, height: height) + host.layoutSubtreeIfNeeded() + return HostedView(view: host, window: window) + } + + private func tabControls(in hosted: HostedView) -> [ResultSetTabsControl] { + tabControls(in: hosted.view) + } + + private func tabControls(in view: NSView) -> [ResultSetTabsControl] { + let own = (view as? ResultSetTabsControl).map { [$0] } ?? [] + return own + view.subviews.flatMap { tabControls(in: $0) }.filter { !$0.isHiddenOrHasHiddenAncestor } + } + + private static func model(labels: [String], activeIndex: Int = 0, pinnedIndices: Set = []) -> ResultSetMenuModel { + let entries = labels.enumerated().map { index, label in + ResultSetMenuEntry( + id: UUID(), + label: label, + isPinned: pinnedIndices.contains(index), + isActive: index == activeIndex, + ordinal: index + 1, + isComparable: true + ) + } + return ResultSetMenuModel(entries: entries, activeOrdinal: activeIndex + 1, total: labels.count) + } +} + +private struct HostedView { + let view: NSView + let window: NSWindow +} + +@MainActor +private final class BarFrames { + var top: CGRect? + var bottom: CGRect? +} diff --git a/apple/TableProUITests/QueryRunUITests.swift b/apple/TableProUITests/QueryRunUITests.swift index 6c8b1fffe8..661e5c373a 100644 --- a/apple/TableProUITests/QueryRunUITests.swift +++ b/apple/TableProUITests/QueryRunUITests.swift @@ -67,5 +67,15 @@ final class QueryRunUITests: UITestCase { XCTAssertTrue(names.contains { $0.hasPrefix("SELECT 2") }, "A bare statement is named after its text, got \(names)") XCTAssertFalse(names.contains { $0.hasPrefix("Result ") }, "No result may fall back to its position, got \(names)") app.typeKey(.escape, modifierFlags: []) + + let tabs = resultSetTabs(in: window) + XCTAssertTrue(tabs.waitToExist(timeout: 10), "Two results must be offered as tabs") + let firstResult = resultSetTab(containing: "monthly totals", in: tabs) + XCTAssertTrue(waitUntilHittable(firstResult, timeout: 10), "Each result must have its own tab") + firstResult.click() + XCTAssertTrue( + waitForPredicate(timeout: 10) { chooser.title.hasPrefix("1 of 2") || chooser.title.hasSuffix("1/2") }, + "One click on a tab must show that result, got \(chooser.title)" + ) } } diff --git a/apple/TableProUITests/Support/UITestCase+EditorTabs.swift b/apple/TableProUITests/Support/UITestCase+EditorTabs.swift index 5a52ddd39a..e63ccee6db 100644 --- a/apple/TableProUITests/Support/UITestCase+EditorTabs.swift +++ b/apple/TableProUITests/Support/UITestCase+EditorTabs.swift @@ -36,6 +36,18 @@ extension UITestCase { window.descendants(matching: .any).matching(identifier: "result-set-menu").firstMatch } + internal func resultSetTabs(in window: XCUIElement) -> XCUIElement { + window.descendants(matching: .any).matching(identifier: "result-set-tabs").firstMatch + } + + // A tab is a `Tab` element under the tabs role on macOS 27 and a radio button before it, like a view switcher's. + internal func resultSetTab(containing text: String, in tabs: XCUIElement) -> XCUIElement { + let types = [XCUIElement.ElementType.tab, .radioButton].map(\.rawValue) + return tabs.descendants(matching: .any) + .matching(NSPredicate(format: "label CONTAINS %@ AND elementType IN %@", text, types)) + .firstMatch + } + internal func gridCell(showing text: String, in grid: XCUIElement) -> XCUIElement { grid.staticTexts.matching(NSPredicate(format: "value == %@", text)).firstMatch } diff --git a/apple/changelog.d/3924.added.md b/apple/changelog.d/3924.added.md new file mode 100644 index 0000000000..eb382a2cd9 --- /dev/null +++ b/apple/changelog.d/3924.added.md @@ -0,0 +1 @@ +Two to six query results show as tabs above the grid, so switching takes one click. diff --git a/apple/changelog.d/3924.fixed.1.md b/apple/changelog.d/3924.fixed.1.md new file mode 100644 index 0000000000..bd290c69aa --- /dev/null +++ b/apple/changelog.d/3924.fixed.1.md @@ -0,0 +1 @@ +Closing or clearing a result dropped its unsaved cell edits without asking. diff --git a/apple/changelog.d/3924.fixed.2.md b/apple/changelog.d/3924.fixed.2.md new file mode 100644 index 0000000000..67ffaba29a --- /dev/null +++ b/apple/changelog.d/3924.fixed.2.md @@ -0,0 +1 @@ +Close Result Tab emptied a table tab, and Previous Result and Next Result stayed enabled with nowhere to go. diff --git a/apple/changelog.d/3924.fixed.3.md b/apple/changelog.d/3924.fixed.3.md new file mode 100644 index 0000000000..64c84ece5f --- /dev/null +++ b/apple/changelog.d/3924.fixed.3.md @@ -0,0 +1 @@ +Dismissing a failed statement's error turned its result into a success message. diff --git a/apple/changelog.d/3924.fixed.4.md b/apple/changelog.d/3924.fixed.4.md new file mode 100644 index 0000000000..577b088cf9 --- /dev/null +++ b/apple/changelog.d/3924.fixed.4.md @@ -0,0 +1 @@ +A script's error banner stayed over the results of the statements that succeeded. diff --git a/apple/changelog.d/3924.fixed.5.md b/apple/changelog.d/3924.fixed.5.md new file mode 100644 index 0000000000..5205b4b701 --- /dev/null +++ b/apple/changelog.d/3924.fixed.5.md @@ -0,0 +1 @@ +Long result names were cut through an emoji. diff --git a/apple/changelog.d/3924.fixed.6.md b/apple/changelog.d/3924.fixed.6.md new file mode 100644 index 0000000000..8ed46333c6 --- /dev/null +++ b/apple/changelog.d/3924.fixed.6.md @@ -0,0 +1 @@ +A new run numbered its results from 1 even when pinned results already used those numbers. diff --git a/apple/changelog.d/3924.fixed.md b/apple/changelog.d/3924.fixed.md new file mode 100644 index 0000000000..f9352de880 --- /dev/null +++ b/apple/changelog.d/3924.fixed.md @@ -0,0 +1 @@ +Closing a result jumped to the last result instead of the one beside it.