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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -514,7 +514,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- App hanging for a minute when an import stopped on a failing statement several megabytes long.
- SQL Server Windows Authentication to another realm failing when the service principal name is over 128 bytes.
- Data grid's inline cell editor and cell viewer unreachable by VoiceOver.
- No Executing indicator or Stop button in the results status bar while a query tab runs its first query.
- No Executing indicator or Stop button for a query tab with no result grid, in Output mode or on a query plan.

### Security

Expand Down
6 changes: 4 additions & 2 deletions TablePro/Core/Storage/SessionRecoveryTracker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import Foundation

@MainActor
enum SessionRecoveryTracker {
private static let storage: LastOpenConnectionsStorage? = NSClassFromString("XCTestCase") == nil ? .shared : nil

/// Connections eligible for "Reopen Last Session": one the user actually worked in,
/// or one whose window is still holding the intent to reach it. A cancelled attempt
/// and a closing window are both excluded, so neither is replayed on the next launch.
Expand Down Expand Up @@ -40,7 +42,7 @@ enum SessionRecoveryTracker {
/// changes so the file stays correct after a crash or a force quit, neither of
/// which runs `applicationWillTerminate`.
static func sync() {
guard !MainContentCoordinator.isAppTerminating else { return }
LastOpenConnectionsStorage.shared.save(connectionIds: connectionIds())
guard !MainContentCoordinator.isAppTerminating, let storage else { return }
storage.save(connectionIds: connectionIds())
}
}
4 changes: 4 additions & 0 deletions TablePro/Models/Query/QueryTab.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ enum ResultsViewMode: String, CaseIterable, Equatable {
self != .structure && self != .output
}

var reportsExecution: Bool {
self != .structure
}

var showsColumnControls: Bool {
self == .data || self == .json
}
Expand Down
4 changes: 2 additions & 2 deletions TablePro/Models/Query/ResultStatusModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ enum ResultStatusReadout: Equatable {
struct ResultStatusControls: Equatable {
var showsModeSwitcher = false
var showsReadout = false
var showsExecutionWithoutReadout = false
var showsExecution = false
var showsLoadingMore = false
var showsExactCountAction = false
var showsCountInProgress = false
Expand Down Expand Up @@ -90,6 +90,7 @@ struct ResultStatusModel: Equatable {

controls.showsModeSwitcher = snapshot.availableModes.count > 1
controls.showsStructureActions = viewMode == .structure && snapshot.hasStructureActions
controls.showsExecution = viewMode.reportsExecution

/// A plan keeps the bar so it stays choosable and pinnable, and gives up everything the bar
/// says about rows. It has none, and reporting "No rows" under a plan states something
Expand All @@ -105,7 +106,6 @@ struct ResultStatusModel: Equatable {
let describesAResult = isTable ? snapshot.hasTableName : snapshot.hasColumns

controls.showsReadout = viewMode.showsResultScope && describesAResult
controls.showsExecutionWithoutReadout = viewMode.showsResultScope && !describesAResult && pagination.isLoading
controls.showsLoadingMore = controls.showsReadout && pagination.isLoadingMore

/// Withheld until nothing is still resolving the total. Offered against a total that is
Expand Down
18 changes: 14 additions & 4 deletions TablePro/Views/Results/ExecutionIndicatorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ struct ExecutionIndicatorView: View {
/// whose commit is on the wire passes false: the spinner stays and the button dims, rather than
/// offering a cancel that cannot reach the server.
var canStop = true
var leadsWithSeparator = false
var onCancel: (() -> Void)?

/// Held back rather than the spinner inside it, so a query too fast to report leaves the
Expand Down Expand Up @@ -50,6 +51,19 @@ struct ExecutionIndicatorView: View {
}

var body: some View {
HStack(spacing: 6) {
if leadsWithSeparator, showsExecution || lastTiming != nil {
StatusBarSeparator()
}
report
}
.onChange(of: isExecuting) { nowExecuting in
if nowExecuting { showsBreakdown = false }
}
.loadingRevealGate(isActive: isExecuting, isRevealed: $showsExecution)
}

private var report: some View {
HStack(spacing: 4) {
if showsExecution {
ProgressView()
Expand All @@ -75,10 +89,6 @@ struct ExecutionIndicatorView: View {
durationReadout(timing)
}
}
.onChange(of: isExecuting) { nowExecuting in
if nowExecuting { showsBreakdown = false }
}
.loadingRevealGate(isActive: isExecuting, isRevealed: $showsExecution)
}

// MARK: - Readout
Expand Down
6 changes: 0 additions & 6 deletions TablePro/Views/Results/ExecutionReadout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,6 @@ struct ExecutionReadout: Equatable {
execution.isStoppable(tabId)
}

/// Nothing to draw when no query has run and none is running. The toolbar used to hold an
/// em-dash placeholder there, which spent width to say nothing.
var isActive: Bool {
isExecuting || lastTiming != nil
}

static func == (lhs: ExecutionReadout, rhs: ExecutionReadout) -> Bool {
lhs.isExecuting == rhs.isExecuting && lhs.canStop == rhs.canStop && lhs.lastTiming == rhs.lastTiming
}
Expand Down
124 changes: 54 additions & 70 deletions TablePro/Views/Results/ResultStatusBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -109,13 +109,7 @@ struct ResultStatusBar: View {
onCloseOthers: onCloseOtherResultSets
)
}
if model.controls.showsReadout {
readoutZone(readoutCluster)
} else if model.controls.showsExecutionWithoutReadout {
readoutZone(executionIndicator)
} else {
Spacer(minLength: 0)
}
readoutZone(readoutCluster)
controlCluster(presentation)
}
}
Expand Down Expand Up @@ -151,70 +145,67 @@ struct ResultStatusBar: View {
/// clusters on either side keep their intrinsic widths.
private var readoutCluster: some View {
HStack(spacing: 6) {
if model.controls.showsLoadingMore {
ProgressView()
.controlSize(.small)
.accessibilityHidden(true)
Text("Loading…")
.font(.caption)
.foregroundStyle(.secondary)
} else {
ResultStatusReadoutView(readout: model.readout)
if model.controls.showsReadout {
resultReadout
}

if model.controls.showsCountInProgress {
ProgressView()
.controlSize(.small)
.accessibilityLabel(String(localized: "Counting rows"))
if model.controls.showsExecution {
executionIndicator
}

if model.controls.showsExactCountAction {
Button(
String(localized: "Count Exactly"),
action: paginationCallbacks.onRequestExactCount
)
.accessoryBarActionStyle()
.help(String(localized: "Replace the estimate with an exact row count."))
.accessibilityIdentifier("result-status-count-exactly")
if model.controls.showsReadout, isRefreshingSchema {
DelayedProgressIndicator(isActive: true)
.accessibilityLabel(String(localized: "Refreshing"))
}
}
}

if model.controls.showsFetchAll, let onFetchAll {
Button(String(localized: "Fetch All"), action: onFetchAll)
.accessoryBarActionStyle()
.help(String(localized: "Load the rows the row cap left behind."))
.accessibilityIdentifier("result-status-fetch-all")
}
@ViewBuilder
private var resultReadout: some View {
if model.controls.showsLoadingMore {
ProgressView()
.controlSize(.small)
.accessibilityHidden(true)
Text("Loading…")
.font(.caption)
.foregroundStyle(.secondary)
} else {
ResultStatusReadoutView(readout: model.readout)
}

if let statusMessage = model.statusMessage {
separator
/// Yields its width before the sentence beside it does, so a wordy driver message
/// truncates instead of squeezing out the row count. Which tier the bar draws is not
/// its business: the enclosing frame reports a constant ideal width so no message
/// length can change that choice.
Text(statusMessage)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
.layoutPriority(-1)
}
if model.controls.showsCountInProgress {
ProgressView()
.controlSize(.small)
.accessibilityLabel(String(localized: "Counting rows"))
}

executionReadout
if model.controls.showsExactCountAction {
Button(
String(localized: "Count Exactly"),
action: paginationCallbacks.onRequestExactCount
)
.accessoryBarActionStyle()
.help(String(localized: "Replace the estimate with an exact row count."))
.accessibilityIdentifier("result-status-count-exactly")
}
}

/// Whether a query is running and how long the last one took, beside the rows it produced. It
/// used to be a hosted SwiftUI view in the centre of the toolbar, where AppKit dropped it whole
/// before any command as soon as the window narrowed.
@ViewBuilder
private var executionReadout: some View {
if execution.isActive {
separator
executionIndicator
if model.controls.showsFetchAll, let onFetchAll {
Button(String(localized: "Fetch All"), action: onFetchAll)
.accessoryBarActionStyle()
.help(String(localized: "Load the rows the row cap left behind."))
.accessibilityIdentifier("result-status-fetch-all")
}
if isRefreshingSchema {
DelayedProgressIndicator(isActive: true)
.accessibilityLabel(String(localized: "Refreshing"))

if let statusMessage = model.statusMessage {
StatusBarSeparator()
/// Yields its width before the sentence beside it does, so a wordy driver message
/// truncates instead of squeezing out the row count. Which tier the bar draws is not
/// its business: the enclosing frame reports a constant ideal width so no message
/// length can change that choice.
Text(statusMessage)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
.layoutPriority(-1)
}
}

Expand All @@ -223,6 +214,7 @@ struct ResultStatusBar: View {
isExecuting: execution.isExecuting,
lastTiming: execution.lastTiming,
canStop: execution.canStop,
leadsWithSeparator: model.controls.showsReadout,
onCancel: execution.onCancel
)
}
Expand All @@ -236,14 +228,6 @@ struct ResultStatusBar: View {
)
}

/// Punctuation, so VoiceOver must not read it as an element of its own.
private var separator: some View {
Text(verbatim: "·")
.font(.caption)
.foregroundStyle(.tertiary)
.accessibilityHidden(true)
}

// MARK: - Controls

@ViewBuilder
Expand Down
9 changes: 9 additions & 0 deletions TablePro/Views/Results/StatusBarChrome.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ enum StatusBarChrome {
static let clusterSpacing: CGFloat = 8
}

internal struct StatusBarSeparator: View {
internal var body: some View {
Text(verbatim: "·")
.font(.caption)
.foregroundStyle(.tertiary)
.accessibilityHidden(true)
}
}

/// `NSVisualEffectView` rather than a flat colour because a bar is window chrome: AppKit desaturates
/// the material when the window stops being key, and a `Color` never does.
private struct StatusBarMaterial: NSViewRepresentable {
Expand Down
Loading
Loading