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: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed

- Fixed a crash on macOS 26 and later when the editor redrew a diagnostic underline or search highlight whose text had been edited away.
- The toolbar can no longer sit on "Executing…" after a query has ended, so Stop is not the only way back. (#2342)
- The session context buttons no longer empty out for the length of every query.
- Fixed a crash when an input method, dictation or Look Up asked the editor about text that had already been edited away. (#2339)
- A search highlight or diagnostic underline whose text you delete now disappears, instead of staying put or jumping to the end of the editor. (#2341)
- Fixed crashes when the editor's layout, syntax highlighting or accessibility read text that a newer edit had already removed. (#2340)
Expand Down
9 changes: 7 additions & 2 deletions TablePro/Core/Coordinators/PaginationCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,6 @@ final class PaginationCoordinator {
parent.cancelAllRowCountTasks()
parent.releaseAllExactCounts()
parent.reportEndedExecutions(parent.tabExecution.invalidateAll(reason: .cancelledByUser))
parent.toolbarState.setExecuting(false)
for idx in parent.tabManager.tabs.indices where parent.tabManager.tabs[idx].pagination.isBusy {
parent.tabManager.mutate(at: idx) { tab in
tab.pagination.isLoadingMore = false
Expand Down Expand Up @@ -288,12 +287,18 @@ final class PaginationCoordinator {
let storedParamValues = parent.tabManager.tabs[idx].pagination.baseQueryParameterValues

parent.tabManager.mutate(at: idx) { $0.pagination.isLoadingMore = true }
parent.toolbarState.setExecuting(true)

/// Fetch All extends the result already on screen instead of replacing it, so it validates
/// against the tab's content epoch and cannot claim the tab: claiming mints a new epoch and
/// would discard its own rows. It registers as unclaimed work instead, which is what keeps
/// the titlebar reporting it, and releases that on every exit including cancellation.
let workToken = parent.tabExecution.beginUnclaimedWork(for: tabId)

let route = DatabaseManager.shared.executionRoute(for: scope)

let startedAt = ContinuousClock.Instant.now
let fetchAllTask = Task { [weak self, parent] in
defer { parent.tabExecution.endUnclaimedWork(workToken, for: tabId) }
guard let self, !parent.isTearingDown else { return }

do {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,6 @@ extension QueryExecutionCoordinator {
tab.execution.errorMessage = nil
}
let tab = parent.tabManager.tabs[index]
parent.toolbarState.setExecuting(true)

if PluginManager.shared.supportsQueryProgress(for: parent.connection.type) {
parent.installClickHouseProgressHandler()
Expand Down Expand Up @@ -269,7 +268,6 @@ extension QueryExecutionCoordinator {
tab.execution.executionTime = nil
tab.execution.errorMessage = nil
}
parent.toolbarState.setExecuting(true)

let conn = parent.connection
let tabId = parent.tabManager.tabs[index].id
Expand Down
41 changes: 40 additions & 1 deletion TablePro/Core/Execution/TabExecutionRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ internal struct EndedExecution: Equatable, Sendable {
/// handles that a tab retarget participated in none of. Busy state is derived from membership here,
/// never stored on the tab, because a stored flag is exactly what let a retargeted tab stay busy
/// forever and silently swallow every later navigation.
///
/// The window's chrome derives from it too. A second copy of the same fact lived on
/// `ConnectionToolbarState`, raised and lowered by hand beside each execution and released only
/// behind two ownership checks, so any path that ended an execution without satisfying both left
/// the titlebar, Stop, `Cmd+.` and the disconnect warning describing work that was over (#2342).
internal struct TabExecutionRegistry {
private struct Entry {
let epoch: Int
Expand All @@ -58,6 +63,7 @@ internal struct TabExecutionRegistry {

private var entries: [UUID: Entry] = [:]
private var contentEpochs: [UUID: Int] = [:]
private var unclaimedWork: [UUID: Set<UUID>] = [:]
private var lastEpoch: Int = 0

internal init() {}
Expand Down Expand Up @@ -102,6 +108,7 @@ internal struct TabExecutionRegistry {
let ended = entries.removeValue(forKey: tabId).map {
EndedExecution(tabId: tabId, startedAt: $0.startedAt, reason: reason)
}
unclaimedWork.removeValue(forKey: tabId)
lastEpoch += 1
contentEpochs[tabId] = lastEpoch
return ended
Expand All @@ -113,13 +120,40 @@ internal struct TabExecutionRegistry {
EndedExecution(tabId: $0.key, startedAt: $0.value.startedAt, reason: reason)
}
entries.removeAll()
unclaimedWork.removeAll()
for tabId in tabIds {
lastEpoch += 1
contentEpochs[tabId] = lastEpoch
}
return ended
}

/// Work that runs against a tab without owning its result.
///
/// Fetch All is why this exists. `claim` mints a new content epoch, which is the very value the
/// fetch validates against before writing its rows back, so claiming would discard the result it
/// runs to extend. It still has to count as busy, or the window reports idle while it works.
///
/// Keyed by tab like everything else here, so `invalidate` and `invalidateAll` release it on the
/// same terms as a claim. A token tied to nothing could only ever be released by the one
/// function that minted it, which is the shape this file exists to get rid of.
internal mutating func beginUnclaimedWork(for tabId: UUID) -> UUID {
let token = UUID()
unclaimedWork[tabId, default: []].insert(token)
return token
}

/// Takes no `ExecutionEndReason` because there is nothing to report: this is the completion
/// path, the counterpart of `settle`, not one of the ways an execution is ended from outside.
/// Ending a token the registry no longer holds is a no-op, so work unwinding after Stop has
/// already cleared everything cannot put the window back to busy.
internal mutating func endUnclaimedWork(_ token: UUID, for tabId: UUID) {
unclaimedWork[tabId]?.remove(token)
if unclaimedWork[tabId]?.isEmpty == true {
unclaimedWork.removeValue(forKey: tabId)
}
}

/// Ends a claim that ran to completion and reports whether it still owned the tab. A claim that
/// is no longer current settles nothing, so a late result cannot clear the busy state of the
/// navigation that superseded it.
Expand All @@ -142,7 +176,12 @@ internal struct TabExecutionRegistry {
entries[tabId] != nil
}

/// The window's whole answer to "is anything running here", and the only one.
///
/// The toolbar's indicator, Stop, `Cmd+.` and the disconnect warning all read this rather than
/// a flag raised and lowered by hand beside each execution. A stored copy is what let the
/// titlebar report a query that had already ended, recoverable only by pressing Stop (#2342).
internal var isAnyExecuting: Bool {
!entries.isEmpty
!entries.isEmpty || !unclaimedWork.isEmpty
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ struct SessionContextToolbarButton: View {
.help(context.label)
}
}
/// Keyed on the connection alone. It used to reload on every query as well, because the
/// toolbar's `executing` case made one look like a connection change, and the load then
/// refused to run and emptied the row of buttons for the query's duration. The only driver
/// that answers `fetchSessionContexts` is Snowflake, which pays two round trips for it, so
/// per-query reloading was not free either. A context the reader switches reloads itself.
.task(id: coordinator.toolbarState.connectionState) {
await coordinator.loadSessionContexts()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,15 @@ extension MainWindowToolbar: NSToolbarItemValidation {
}

/// Listed exhaustively so a new state has to choose a side instead of inheriting "alive".
///
/// `.connecting` counts because the health monitor writes it on every reconnect attempt, and
/// the window keeps showing the session's tabs and rows throughout. Graying the whole toolbar
/// out for the length of a backoff would take Sidebar Toggle with it.
static func hasLiveSession(_ state: ToolbarConnectionState) -> Bool {
switch state {
case .connected, .executing:
case .connected, .connecting:
return true
case .disconnected, .connecting, .error:
case .disconnected, .error:
return false
}
}
Expand Down
120 changes: 28 additions & 92 deletions TablePro/Models/Connection/ConnectionToolbarState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
// TablePro
//
// Observable state container for toolbar connection information.
// Centralizes all toolbar-related state in a single, composable object.
//

import AppKit
Expand All @@ -13,60 +12,39 @@ import TableProPluginKit

// MARK: - Connection State

/// Represents the current state of the database connection
/// The state of the database connection, and only that.
///
/// Whether a query is running is a separate axis owned by `TabExecutionRegistry`. The two used to
/// share one `executing` case, which let a connection that was merely dialing paint the query
/// indicator, and made every consumer that asked "is this connection up" answer no for the whole
/// duration of any query (#2342).
enum ToolbarConnectionState: Equatable {
case disconnected
case connecting
case connected
case executing
case error(String)

/// Status indicator color
var indicatorColor: Color {
switch self {
case .disconnected: return .gray
case .connecting: return .orange
case .connected: return .green
case .executing: return .blue
case .error: return .red
}
}

/// Human-readable description
var description: String {
switch self {
case .disconnected: return String(localized: "Disconnected")
case .connecting: return String(localized: "Connecting…")
case .connected: return String(localized: "Connected")
case .executing: return String(localized: "Executing…")
case .error(let message): return String(format: String(localized: "Error: %@"), message)
}
}

/// Short label for toolbar display
var label: String {
switch self {
case .disconnected: return String(localized: "Disconnected")
case .connecting: return String(localized: "Connecting")
case .connected: return String(localized: "Connected")
case .executing: return String(localized: "Executing")
case .error: return String(localized: "Error")
}
}

/// Whether to show activity indicator
var isAnimating: Bool {
switch self {
case .connecting, .executing: return true
default: return false
/// The one mapping from a session's status to what the titlebar shows. Three copies of it used
/// to exist, and two of them dropped the failure's message, so the same failed connection
/// compared unequal to itself and the state was rewritten on every notification.
init(status: ConnectionStatus) {
switch status {
case .disconnected: self = .disconnected
case .connecting: self = .connecting
case .connected: self = .connected
case .error(let message): self = .error(message)
}
}
}

// MARK: - Toolbar State

/// Observable state container for the connection toolbar.
/// This is the single source of truth for all toolbar UI state.
/// Observable state container for the connection toolbar's connection and session state: which
/// database, which schema, which safe mode, what the tab holds.
///
/// Whether anything is running is NOT here. That is derived from `TabExecutionRegistry`, which is
/// the only thing that knows, and a stored copy of it on this object is what let the titlebar
/// report a query that had already ended (#2342). Do not reintroduce one.
@Observable
@MainActor
final class ConnectionToolbarState {
Expand Down Expand Up @@ -105,26 +83,6 @@ final class ConnectionToolbarState {

// MARK: - Query Execution

/// Whether a query is currently executing.
private(set) var isExecuting: Bool = false

/// Set execution state and update connectionState atomically.
func setExecuting(_ executing: Bool) {
let newState: ToolbarConnectionState
if executing && connectionState == .connected {
newState = .executing
} else if !executing && connectionState == .executing {
newState = .connected
} else {
newState = connectionState
}

guard executing != isExecuting || newState != connectionState else { return }

isExecuting = executing
connectionState = newState
}

/// Duration of the last completed query
var lastQueryDuration: TimeInterval?

Expand Down Expand Up @@ -199,23 +157,6 @@ final class ConnectionToolbarState {
)
}

/// Tooltip text for the status indicator
var statusTooltip: String {
var parts: [String] = [connectionState.description]

if let latency = latencyMs {
parts.append(String(format: String(localized: "Latency: %dms"), latency))
}

if let lag = replicationLagSeconds {
parts.append(String(format: String(localized: "Replication lag: %ds"), lag))
}

parts.append(safeModeLevel.displayName)

return parts.joined(separator: " • ")
}

// MARK: - Initialization

init() {}
Expand Down Expand Up @@ -266,18 +207,14 @@ final class ConnectionToolbarState {
}
}

/// Update connection state from ConnectionStatus
/// Update connection state from ConnectionStatus.
///
/// Guarded like every other write on this object: a redundant write still notifies observers,
/// and one of them keys a `task(id:)` on this value.
func updateConnectionState(from status: ConnectionStatus) {
switch status {
case .disconnected:
connectionState = .disconnected
case .connecting:
connectionState = .connecting
case .connected:
connectionState = isExecuting ? .executing : .connected
case .error(let message):
connectionState = .error(message)
}
let resolved = ToolbarConnectionState(status: status)
guard connectionState != resolved else { return }
connectionState = resolved
}

/// Reset to default disconnected state
Expand All @@ -291,7 +228,6 @@ final class ConnectionToolbarState {
databaseGroupingStrategy = .byDatabase
displayColor = databaseType.themeColor
connectionState = .disconnected
isExecuting = false
lastQueryDuration = nil
clickHouseProgress = nil
lastClickHouseProgress = nil
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,6 @@ extension MainContentCoordinator {
let conn = connection

tabManager.mutate(at: index) { $0.execution.errorMessage = nil }
toolbarState.setExecuting(true)

let explainTask = Task { [weak self] in
guard let self else { return }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ extension MainContentCoordinator {
static func hasRunningQuery(forConnection connectionId: UUID) -> Bool {
activeCoordinators.values
.filter { $0.connectionId == connectionId }
.contains { $0.toolbarState.isExecuting }
.contains { $0.tabExecution.isAnyExecuting }
}

static func allTabs(for connectionId: UUID) -> [QueryTab] {
Expand Down
14 changes: 1 addition & 13 deletions TablePro/Views/Main/Extensions/MainContentView+Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,7 @@ extension MainContentView {
coordinator.lazyLoadCurrentTabIfNeeded()
}
}
let mappedState = mapSessionStatus(session.status)
if mappedState != toolbarState.connectionState {
toolbarState.connectionState = mappedState
}
toolbarState.updateConnectionState(from: session.status)
toolbarState.syncFromSession(for: connection)
}

Expand All @@ -53,15 +50,6 @@ extension MainContentView {
}
}

private func mapSessionStatus(_ status: ConnectionStatus) -> ToolbarConnectionState {
switch status {
case .connected: return .connected
case .connecting: return .executing
case .disconnected: return .disconnected
case .error: return .error("")
}
}

// MARK: - Inspector Context

func scheduleInspectorUpdate() {
Expand Down
2 changes: 1 addition & 1 deletion TablePro/Views/Main/MainContentCommandActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ final class MainContentCommandActions {
/// existing proves nothing: it is kept alive across a lost session so a reconnect can restore
/// the user's tabs.
var isConnected: Bool { coordinator?.splitViewController?.isConnected ?? false }
var isQueryExecuting: Bool { coordinator?.toolbarState.isExecuting ?? false }
var isQueryExecuting: Bool { coordinator?.tabExecution.isAnyExecuting ?? false }

var safeModeLevel: SafeModeLevel { coordinator?.toolbarState.safeModeLevel ?? connection.safeModeLevel }

Expand Down
Loading
Loading