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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -510,19 +513,19 @@ 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)
)
}
} 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(_:)):
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
30 changes: 21 additions & 9 deletions apple/TablePro/Models/Query/QueryResultPresentation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
34 changes: 34 additions & 0 deletions apple/TablePro/Models/Query/QueryTab+Errors.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
11 changes: 9 additions & 2 deletions apple/TablePro/Models/Query/QueryTabState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand Down
2 changes: 2 additions & 0 deletions apple/TablePro/Models/Query/ResultSet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
74 changes: 47 additions & 27 deletions apple/TablePro/Models/Query/ResultSetMenuModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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[..<index].filter { $0 == labels[index] }.count
return earlier > 0 ? earlier + 1 : nil
}

// A pinned result is pinned precisely so that nothing takes it away.
func canClose(_ entry: ResultSetMenuEntry) -> Bool {
!entry.isPinned
}
Expand All @@ -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
}
4 changes: 1 addition & 3 deletions apple/TablePro/Models/Query/StatementAnchor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading