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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Opening a saved connection that fails now shows the detailed troubleshooting dialog with suggested fixes, the same one Test Connection shows, instead of a generic error alert. (#1425, #483)
- Oracle connection errors no longer surface the driver's raw internal message; failures now explain the cause in plain language. (#483)
- AWS IAM authentication with a named profile now reads `~/.aws/config` (not just `~/.aws/credentials`) and supports `credential_process`, so profiles backed by SSO, IAM Identity Center, or assume-role work through `aws configure export-credentials`. (#1291)
- Opening a table no longer runs the initial query multiple times before the data arrives. The same query could fire up to four times on a single tab open; it now runs once.
- iOS: a connection's Safe Mode setting now survives relaunch. iCloud sync no longer drops the value, so a connection set to Confirm Writes or Read-Only no longer reverts to Off after reopening the app.
- iOS: running a query that returns a very large result no longer crashes the app. The query editor keeps the first rows it loads, stops before memory runs low, and tells you to add LIMIT to fetch more.
- iOS: Safe Mode "Confirm Writes" now prompts before saving a row edit or inserting a row, matching the query editor. Previously grid edits and inserts saved with no confirmation.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,47 +105,50 @@ extension MainContentCoordinator {

// MARK: - Lazy Load

/// Execute the current tab's query if it is a table tab whose row data is
/// missing or evicted. Apple-pattern guards in cheap-content-first order:
/// trivial content checks reject before the expensive connection probe.
/// Idempotent — repeated calls with the same state are no-ops.
func lazyLoadCurrentTabIfNeeded() {
guard let tab = tabManager.selectedTab else { return }
guard tab.tabType == .table else { return }
guard tab.execution.errorMessage == nil else { return }
guard !tab.content.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
guard canAutoLoadTableTab(tab) else { return }
guard tableLoadTasks[tab.id] == nil else { return }

let rows = tabSessionRegistry.tableRows(for: tab.id)
let isEvicted = tabSessionRegistry.isEvicted(tab.id)
let hasFreshRows = !rows.rows.isEmpty && !isEvicted
let hasExecuted = tab.execution.lastExecutedAt != nil && !isEvicted
guard !hasFreshRows, !hasExecuted else { return }

let hasPendingEdits =
changeManager.hasChanges
|| tab.pendingChanges.hasChanges
guard !hasPendingEdits else { return }

// A previous load that was cancelled mid-flight (e.g. user rapidly
// switched away) leaves `isExecuting = true` with no rows and no
// `lastExecutedAt`. Clear the stale flag inline so the executor's
// own `!tab.execution.isExecuting` guard inside
// `executeTableTabQueryDirectly` doesn't suppress this re-fire.
if tab.execution.isExecuting && rows.rows.isEmpty && tab.execution.lastExecutedAt == nil {
tabManager.mutate(tabId: tab.id) { $0.execution.isExecuting = false }
} else if tab.execution.isExecuting {
return
}
clearAbandonedExecutingFlagIfNeeded(for: tab)

guard let session = DatabaseManager.shared.session(for: connectionId),
session.isConnected else {
needsLazyLoad = true
return
}

let tabId = tab.id
Self.lifecycleLogger.debug(
"[switch] coordinator.lazyLoadCurrentTabIfNeeded executing tabId=\(tab.id, privacy: .public) evicted=\(isEvicted)"
"[switch] coordinator.lazyLoadCurrentTabIfNeeded executing tabId=\(tabId, privacy: .public)"
)
executeTableTabQueryDirectly()
tableLoadTasks[tabId] = Task { @MainActor [weak self] in
guard let self else { return }
defer { self.tableLoadTasks[tabId] = nil }
self.executeTableTabQueryDirectly()
if let task = self.currentQueryTask {
await task.value
}
}
}

private func canAutoLoadTableTab(_ tab: QueryTab) -> Bool {
guard tab.tabType == .table else { return false }
guard tab.execution.errorMessage == nil else { return false }
guard !tab.content.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false }

let rows = tabSessionRegistry.tableRows(for: tab.id)
let isEvicted = tabSessionRegistry.isEvicted(tab.id)
let hasFreshRows = !rows.rows.isEmpty && !isEvicted
let hasExecuted = tab.execution.lastExecutedAt != nil && !isEvicted
guard !hasFreshRows, !hasExecuted else { return false }

let hasPendingEdits = changeManager.hasChanges || tab.pendingChanges.hasChanges
return !hasPendingEdits
}

private func clearAbandonedExecutingFlagIfNeeded(for tab: QueryTab) {
guard tab.execution.isExecuting, currentQueryTask == nil else { return }
tabManager.mutate(tabId: tab.id) { $0.execution.isExecuting = false }
}
}
4 changes: 2 additions & 2 deletions TablePro/Views/Main/Extensions/MainContentView+Setup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ extension MainContentView {
{
await coordinator.switchDatabase(to: selectedTab.tableContext.databaseName)
} else {
coordinator.executeTableTabQueryDirectly()
coordinator.lazyLoadCurrentTabIfNeeded()
}
} else {
coordinator.needsLazyLoad = true
Expand Down Expand Up @@ -157,7 +157,7 @@ extension MainContentView {
{
Task { await coordinator.switchDatabase(to: firstTab.tableContext.databaseName) }
} else {
coordinator.executeTableTabQueryDirectly()
coordinator.lazyLoadCurrentTabIfNeeded()
}
} else {
coordinator.needsLazyLoad = true
Expand Down
3 changes: 3 additions & 0 deletions TablePro/Views/Main/MainContentCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ final class MainContentCoordinator {

@ObservationIgnored internal var queryGeneration: Int = 0
@ObservationIgnored internal var currentQueryTask: Task<Void, Never>?
@ObservationIgnored internal var tableLoadTasks: [UUID: Task<Void, Never>] = [:]
@ObservationIgnored internal var redisDatabaseSwitchTask: Task<Void, Never>?
@ObservationIgnored private var changeManagerUpdateTask: Task<Void, Never>?
@ObservationIgnored private var activeSortTasks: [UUID: Task<Void, Never>] = [:]
Expand Down Expand Up @@ -612,6 +613,8 @@ final class MainContentCoordinator {
fileWatcher = nil
currentQueryTask?.cancel()
currentQueryTask = nil
for task in tableLoadTasks.values { task.cancel() }
tableLoadTasks.removeAll()
changeManagerUpdateTask?.cancel()
changeManagerUpdateTask = nil
redisDatabaseSwitchTask?.cancel()
Expand Down
35 changes: 25 additions & 10 deletions TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -137,20 +137,18 @@ struct MainContentCoordinatorLazyLoadTests {
#expect(coordinator.needsLazyLoad == false)
}

@Test("Returns early when tab is already executing")
func skipsWhenAlreadyExecuting() {
@Test("Returns early when a load Task is already registered for this tab")
func skipsWhenLoadTaskRegistered() {
let (coordinator, tabManager) = makeCoordinator()
let tabId = addTableTab(to: tabManager)
seedRows(coordinator, for: tabId, rowCount: 1)
guard let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else {
Issue.record("expected tab to exist")
return
}
tabManager.tabs[idx].execution.lastExecutedAt = Date()
tabManager.tabs[idx].execution.isExecuting = true
coordinator.tabSessionRegistry.evict(for: tabId)
let inFlight = Task<Void, Never> { _ = try? await Task.sleep(for: .seconds(60)) }
defer { inFlight.cancel() }
coordinator.tableLoadTasks[tabId] = inFlight

coordinator.lazyLoadCurrentTabIfNeeded()

#expect(coordinator.tableLoadTasks.count == 1)
#expect(coordinator.tableLoadTasks[tabId] != nil)
#expect(coordinator.needsLazyLoad == false)
}

Expand Down Expand Up @@ -187,6 +185,23 @@ struct MainContentCoordinatorLazyLoadTests {
#expect(coordinator.needsLazyLoad == false)
}

@Test("Clears an abandoned executing flag when no in-flight task remains")
func recoversAbandonedExecutingFlag() {
let (coordinator, tabManager) = makeCoordinator()
let tabId = addTableTab(to: tabManager)
guard let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else {
Issue.record("expected tab to exist")
return
}
tabManager.tabs[idx].execution.isExecuting = true
coordinator.currentQueryTask = nil

coordinator.lazyLoadCurrentTabIfNeeded()

#expect(tabManager.tabs[idx].execution.isExecuting == false)
#expect(coordinator.needsLazyLoad == true)
}

// MARK: - loadEpoch bump triggers reload after eviction

@Test("Eviction bumps the tab's loadEpoch so .task(id:) re-fires")
Expand Down
Loading