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
8 changes: 4 additions & 4 deletions .github/hub-ratchet-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"TabType cases": 9,
"SettingsPane cases": 12,
"ActiveSheet cases": 17,
"LaunchIntent cases": 16,
"LaunchIntent cases": 15,
"shell extensions named for a module or engine": 11,
"MainContentCoordinator+ files": 67,
"app types AppDelegate names": 65,
Expand All @@ -18,14 +18,14 @@
"files under TablePro/ named for Dameng": 0,
"files under TablePro/ named for Databend": 0,
"files under TablePro/ named for DuckDB": 2,
"files under TablePro/ named for DynamoDB": 3,
"files under TablePro/ named for DynamoDB": 2,
"files under TablePro/ named for Elasticsearch": 1,
"files under TablePro/ named for etcd": 2,
"files under TablePro/ named for HANA": 1,
"files under TablePro/ named for Kafka": 1,
"files under TablePro/ named for libSQL": 0,
"files under TablePro/ named for MariaDB": 0,
"files under TablePro/ named for MongoDB": 8,
"files under TablePro/ named for MongoDB": 5,
"files under TablePro/ named for MySQL": 7,
"files under TablePro/ named for OceanBase": 0,
"files under TablePro/ named for Oracle": 1,
Expand All @@ -36,7 +36,7 @@
"files under TablePro/ named for Snowflake": 0,
"files under TablePro/ named for Spanner": 0,
"files under TablePro/ named for SQL Server": 2,
"files under TablePro/ named for SQLite": 10,
"files under TablePro/ named for SQLite": 8,
"files under TablePro/ named for SurrealDB": 1,
"files under TablePro/ named for Teradata": 0,
"files under TablePro/ named for TiDB": 0,
Expand Down
95 changes: 95 additions & 0 deletions TablePro/Core/Coordinators/BrowseCursorCoordinator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
//
// BrowseCursorCoordinator.swift
// TablePro
//

import Foundation

/// Keeps the strip entry a window shows and the database its connection browses together. A switch
/// from the toolbar, sidebar or strip moves the cursor and the window follows once it lands; picking a
/// tab of another entry moves the window first and the cursor follows.
@MainActor
final class BrowseCursorCoordinator {
unowned let parent: MainContentCoordinator
/// Switches queue on the driver, so one landing after a newer request must not bring back the
/// entry the user already left.
private(set) var pendingRequest: Int?
private var requestCount = 0
/// Not asked for again on activation: dismissing the error sheet makes the window key.
var failedFollow: String?

init(parent: MainContentCoordinator) {
self.parent = parent
}

var browsedContainer: String? {
guard let session = parent.services.databaseManager.session(for: parent.connectionId) else { return nil }
return WorkspaceAnchoring.browsedContainer(of: session, target: parent.tabManager.containerTarget)
}

/// Two windows of a torn-off connection share one cursor, and only the frontmost follows it. Nil
/// from the window manager means no window hosts this coordinator yet.
private var followsBrowseCursor: Bool {
guard let frontmost = WindowManager.shared.frontmostCoordinator(for: parent.connectionId) else { return true }
return frontmost === parent
}

func beginRequest() -> Int {
requestCount &+= 1
pendingRequest = requestCount
return requestCount
}

func endRequest(_ request: Int) -> Bool {
guard pendingRequest == request else { return false }
pendingRequest = nil
return true
}

func showBrowsedContainer() {
parent.tabManager.show(container: browsedContainer)
}

/// A move from outside this window. One arriving during this window's own move belongs to an
/// earlier switch.
func browseContainerDidChange() {
guard pendingRequest == nil, followsBrowseCursor else { return }
showBrowsedContainer()
}

/// A pending switch counts even before the cursor moves: picking a tab of the database being
/// left has to win over it. The saved default database stays, since picking a tab is not
/// choosing one.
func followVisibleContainer() {
guard let container = parent.tabManager.visibleContainer,
container != browsedContainer || pendingRequest != nil,
parent.services.databaseManager.session(for: parent.connectionId)?.isConnected == true,
followsBrowseCursor
else { return }
moveCursor(to: container, persist: false)
}

func followVisibleContainerOnActivation() {
guard parent.tabManager.visibleContainer != failedFollow else { return }
followVisibleContainer()
}

func goToEntry(_ container: String) {
parent.tabManager.show(container: container)
guard container != browsedContainer || pendingRequest != nil else { return }
moveCursor(to: container, persist: true)
}

/// Claimed before the task starts, so a selection made in between sees it in flight and
/// supersedes it.
private func moveCursor(to container: String, persist: Bool) {
failedFollow = nil
let request = beginRequest()
Task { @MainActor [weak self, weak parent] in
guard let self, let parent, self.pendingRequest == request else { return }
await parent.switchContainer(to: container, persist: persist)
guard self.browsedContainer != container, self.pendingRequest == nil else { return }
self.failedFollow = container
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,7 @@ internal enum WorkspaceCloseAction {
/// on an entry the strip no longer lists.
private static func leave(_ workspace: WorkspaceID, in coordinators: [MainContentCoordinator]) {
for coordinator in coordinators where coordinator.tabManager.visibleContainer == workspace.container {
coordinator.showBrowsedContainer()
coordinator.browseCursorCoordinator.showBrowsedContainer()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -544,7 +544,7 @@ internal final class WorkspaceRailViewController: NSViewController {
}

/// Going to an entry of a connection: its tabs come forward, and the object tree follows to its
/// container once the switch lands. `goToEntry` holds both halves, so the order cannot be got
/// container once the switch lands. `BrowseCursorCoordinator.goToEntry` holds both halves, so the order cannot be got
/// wrong here.
private func moveBrowseCursor(of window: NSWindow, to workspace: WorkspaceID) {
guard !workspace.container.isEmpty else {
Expand All @@ -566,7 +566,7 @@ internal final class WorkspaceRailViewController: NSViewController {
return
}
Self.logger.info("moveBrowseCursor to=\(Self.describe(workspace), privacy: .public)")
coordinator.goToEntry(workspace.container)
coordinator.browseCursorCoordinator.goToEntry(workspace.container)
}

/// The menu command is a commit, not a highlight: it names the workspace it goes to, so it
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -466,11 +466,11 @@ extension MainContentCoordinator {
/// `persist` records the database as the connection's saved default.
@discardableResult
func switchDatabase(to database: String, persist: Bool = true) async -> Bool {
let request = beginCursorRequest()
let request = browseCursorCoordinator.beginRequest()
do {
try await DatabaseManager.shared.switchDatabase(to: database, for: connectionId, persist: persist)
if endCursorRequest(request) {
showBrowsedContainer()
if browseCursorCoordinator.endRequest(request) {
browseCursorCoordinator.showBrowsedContainer()
}
toolbarState.currentDatabase = database
toolbarState.currentSchema = DatabaseManager.shared.session(for: connectionId)?.browseSchema
Expand All @@ -481,7 +481,7 @@ extension MainContentCoordinator {
syncSidebarObjectSelection()
return true
} catch {
_ = endCursorRequest(request)
_ = browseCursorCoordinator.endRequest(request)
navigationLogger.error("Failed to switch database: \(error.publicLogShape, privacy: .public)")
/// A user who dismissed the password prompt already knows why nothing happened, and
/// telling them their own decision failed is noise, not news.
Expand Down Expand Up @@ -541,7 +541,7 @@ extension MainContentCoordinator {
let session = services.databaseManager.session(for: connectionId)
/// A switch of this window's own still on its way to somewhere else would land after this
/// one and take the window there, so being on the target already is not enough.
let isSettled = pendingCursorRequest == nil
let isSettled = browseCursorCoordinator.pendingRequest == nil
switch step {
case .database(let name):
guard name != session?.resolvedBrowseDatabase || !isSettled else { continue }
Expand All @@ -557,7 +557,7 @@ extension MainContentCoordinator {
}
/// Already there is still a request to be there: the window may be showing another entry.
if !didSwitch {
showBrowsedContainer()
browseCursorCoordinator.showBrowsedContainer()
}
}

Expand Down Expand Up @@ -587,15 +587,15 @@ extension MainContentCoordinator {
let previousSchema = toolbarState.currentSchema
toolbarState.currentSchema = schema

let request = beginCursorRequest()
let request = browseCursorCoordinator.beginRequest()
do {
try await DatabaseManager.shared.switchSchema(to: schema, for: connectionId)
if endCursorRequest(request) {
showBrowsedContainer()
if browseCursorCoordinator.endRequest(request) {
browseCursorCoordinator.showBrowsedContainer()
}
syncSidebarObjectSelection()
} catch {
_ = endCursorRequest(request)
_ = browseCursorCoordinator.endRequest(request)
/// A switch that waited for the driver is dropped when the connection was closed and
/// opened again before its turn. The toolbar now belongs to that new session, so it is
/// read back from it rather than restored to what the old one showed, and nothing failed
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ extension MainContentCoordinator {
consumeDeferredRestoreLoadIfNeeded()
refreshSourceFileDiskChanges()

followVisibleContainerOnActivation()
browseCursorCoordinator.followVisibleContainerOnActivation()
syncSidebarObjectSelection()
announceActiveTabToVoiceOver()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ extension MainContentCommandActions {
/// The entry this window shows, named the way this engine names containers. Comparing a
/// schema-switching engine's tabs against a database name classified nothing.
var shownContainerName: String {
coordinator?.tabManager.visibleContainer ?? coordinator?.browsedContainer ?? ""
coordinator?.tabManager.visibleContainer ?? coordinator?.browseCursorCoordinator.browsedContainer ?? ""
}

var closeTabsForOtherDatabasesTitle: String {
Expand Down
13 changes: 6 additions & 7 deletions TablePro/Views/Main/MainContentCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,9 @@ final class MainContentCoordinator: ObservableObject {
var browseDatabaseName: String {
services.databaseManager.browseDatabaseName(for: connection)
}
var newTabDatabaseName: String {
tabManager.openingDatabase(browsing: browseDatabaseName)
}
var safeModeLevel: SafeModeLevel { toolbarState.safeModeLevel }
/// The level the toolbar shows, and the floor under it with Agent mode's included, which is what
/// the Safe Mode list offers from and what the toolbar's tooltip explains.
Expand All @@ -172,11 +175,6 @@ final class MainContentCoordinator: ObservableObject {
let queryExecutor: QueryExecutor
internal var executionGate: any ExecutionGate = ExecutionGateProvider.shared
let windowSidebarState: WindowSidebarState
/// The browse-cursor move this window asked for last. Switches queue on the driver, so one that
/// lands after a newer request is stale and must not put back the entry the user already left.
internal var pendingCursorRequest: Int?
internal var cursorRequestCount = 0
internal var failedCursorFollow: String?

// MARK: - Services

Expand All @@ -191,6 +189,7 @@ final class MainContentCoordinator: ObservableObject {
private(set) var queryExecutionCoordinator: QueryExecutionCoordinator!
private(set) var paginationCoordinator: PaginationCoordinator!
private(set) var rowEditingCoordinator: RowEditingCoordinator!
private(set) lazy var browseCursorCoordinator = BrowseCursorCoordinator(parent: self)

/// Stable identifier for this coordinator's window (set by MainContentView on appear)
@Published var windowId: UUID?
Expand Down Expand Up @@ -728,13 +727,13 @@ final class MainContentCoordinator: ObservableObject {
changeManager.undoManagerProvider = { [weak self] in self?.contentWindow?.undoManager }
changeManager.onUndoApplied = { [weak self] result in self?.handleUndoResult(result) }
tabManager.onVisibleContainerReconciled = { [weak self] in
self?.followVisibleContainer()
self?.browseCursorCoordinator.followVisibleContainer()
}
browseContainerCancellable = services.appEvents.browseContainerChanged
.filter { [connectionId = connection.id] changed in changed == connectionId }
.receive(on: RunLoop.main)
.sink { [weak self] _ in
self?.browseContainerDidChange()
self?.browseCursorCoordinator.browseContainerDidChange()
}
tabManager.onTabRetargeted = { [weak self] tabId in
self?.dataTabDelegate?.tableViewCoordinator?.flushPendingColumnLayoutPersistence()
Expand Down
Loading
Loading