diff --git a/.gitignore b/.gitignore index af9f0762c..68905fbf5 100644 --- a/.gitignore +++ b/.gitignore @@ -144,3 +144,6 @@ Libs/*.a Libs/.downloaded Libs/dylibs/ Libs/ios/ + +# Local refactor scratchpad (per chore: untrack docs/refactor scratchpad) +docs/refactor/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ce954a75..7c5dc3d4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Internal: introduce `TabSession` as the foundation type for the editor tab/window subsystem rewrite. Currently a parallel structure mirroring `QueryTab`; subsequent PRs migrate state ownership and lifecycle hooks per `docs/architecture/tab-subsystem-rewrite.md`. No user-visible behavior change in this PR. +- Internal: row data and load epoch now live on `TabSession`. `TabSessionRegistry` exposes the row-access methods directly (`tableRows(for:)`, `setTableRows(_:for:)`, `evict(for:)`, etc.); the intermediate `TableRowsStore` facade is gone. All consumers (coordinator, extensions, views, command actions) now read row data from the registry. No user-visible behavior change. +- Internal: hidden-column state moves from the per-window `ColumnVisibilityManager` into each tab's `columnLayout.hiddenColumns`. The shared manager is removed; `MainContentCoordinator` exposes `hideColumn`, `showColumn`, `toggleColumnVisibility`, `showAllColumns`, `hideAllColumns`, and `pruneHiddenColumns` that mutate the active tab directly. Per-table UserDefaults persistence moves into a small `ColumnVisibilityPersistence` service. Tab-switch save/restore swap is gone — each tab is its own source of truth. No user-visible behavior change. +- Internal: filter state collapses from three places (the per-window `FilterStateManager`, the `TabFilterState` snapshot on `QueryTab`, and the per-table file-based restore) to a single source: `tab.filterState`. The shared manager is removed; `MainContentCoordinator` now exposes the full filter API (`addFilter`, `applyAllFilters`, `clearFilterState`, `toggleFilterPanel`, `setFKFilter`, `saveLastFilters(for:)`, `restoreLastFilters(for:)`, `saveFilterPreset`, `loadFilterPreset`, `generateFilterPreviewSQL`, etc.) that mutates the active tab. The file-based "restore last filters" persistence in `FilterSettingsStorage` is unchanged. `FilterPanelView`, `MainStatusBarView`, `MainContentCommandActions`, `MainContentView`, and `MainEditorContentView` read filter state directly off the active tab. No user-visible behavior change. +- Internal: extract `QueryExecutor` service from `MainContentCoordinator`. Query data fetch, parallel schema fetch, schema parsing, parameter detection, row-cap policy, and DDL detection now live in `TablePro/Core/Services/Query/QueryExecutor.swift`. SQL parsing helpers (`extractTableName`, `stripTrailingOrderBy`, `parseSQLiteCheckConstraintValues`) move into `QuerySqlParser`. Coordinator methods become thin wrappers; behavior unchanged. No user-visible behavior change. + ### Fixed +- Tab switching: rapid Cmd+Number presses no longer leave a tail of tab transitions playing after the user releases the keys. The tab-selection setter (`NSWindowTabGroup.selectedWindow`) is now wrapped in `NSAnimationContext.runAnimationGroup` with `duration = 0`, so AppKit applies each switch synchronously without queuing a CAAnimation. Lazy-load also moved out of `windowDidBecomeKey` into `.task(id:)` view-appearance lifecycle per Apple's documentation. Note: extreme Cmd+Number bursts (e.g. holding the key for key-repeat) still incur per-switch AppKit window-focus overhead; this is platform-inherent to native NSWindow tabs and documented in `docs/architecture/tab-subsystem-rewrite.md` D2 - Oracle TIMESTAMP, TIMESTAMP WITH TIME ZONE, TIMESTAMP WITH LOCAL TIME ZONE, INTERVAL DAY TO SECOND, INTERVAL YEAR TO MONTH, DATE, RAW, and BLOB columns now render through typed decoders instead of garbled text. Tables containing INTERVAL YEAR TO MONTH or BFILE columns no longer crash the app on row fetch. Unknown column types display `` instead of crashing (#965) ## [0.37.0] - 2026-05-01 diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index c200decf4..d6c709ccb 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -350,7 +350,6 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi rightPanelState: rightPanelState, tabManager: sessionState.tabManager, changeManager: sessionState.changeManager, - filterStateManager: sessionState.filterStateManager, toolbarState: sessionState.toolbarState, coordinator: sessionState.coordinator ) diff --git a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift index ffabe045a..0a1f097d8 100644 --- a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift +++ b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift @@ -13,8 +13,6 @@ enum SessionStateFactory { struct SessionState { let tabManager: QueryTabManager let changeManager: DataChangeManager - let filterStateManager: FilterStateManager - let columnVisibilityManager: ColumnVisibilityManager let toolbarState: ConnectionToolbarState let coordinator: MainContentCoordinator } @@ -57,13 +55,15 @@ enum SessionStateFactory { payload: EditorTabPayload? ) -> SessionState { let connectionId = connection.id - let tabMgr = QueryTabManager(globalTabsProvider: { - MainActor.assumeIsolated { MainContentCoordinator.allTabs(for: connectionId) } - }) + let tabSessionRegistry = TabSessionRegistry() + let tabMgr = QueryTabManager( + globalTabsProvider: { + MainActor.assumeIsolated { MainContentCoordinator.allTabs(for: connectionId) } + }, + tabSessionRegistry: tabSessionRegistry + ) let changeMgr = DataChangeManager() changeMgr.databaseType = connection.type - let filterMgr = FilterStateManager() - let colVisMgr = ColumnVisibilityManager() let toolbarSt = ConnectionToolbarState(connection: connection) if let session = DatabaseManager.shared.session(for: connection.id) { @@ -115,7 +115,6 @@ enum SessionStateFactory { } if let initialFilter = payload.initialFilterState { tabMgr.tabs[index].filterState = initialFilter - filterMgr.restoreFromTabState(initialFilter) } } } else { @@ -157,13 +156,15 @@ enum SessionStateFactory { } } + let queryExecutor = QueryExecutor(connection: connection) + let coord = MainContentCoordinator( connection: connection, tabManager: tabMgr, changeManager: changeMgr, - filterStateManager: filterMgr, - columnVisibilityManager: colVisMgr, - toolbarState: toolbarSt + toolbarState: toolbarSt, + tabSessionRegistry: tabSessionRegistry, + queryExecutor: queryExecutor ) // Eagerly publish to the active-coordinator registry so concurrent @@ -176,8 +177,6 @@ enum SessionStateFactory { return SessionState( tabManager: tabMgr, changeManager: changeMgr, - filterStateManager: filterMgr, - columnVisibilityManager: colVisMgr, toolbarState: toolbarSt, coordinator: coord ) diff --git a/TablePro/Core/Services/Query/QueryExecutor.swift b/TablePro/Core/Services/Query/QueryExecutor.swift new file mode 100644 index 000000000..d9c22a1f7 --- /dev/null +++ b/TablePro/Core/Services/Query/QueryExecutor.swift @@ -0,0 +1,266 @@ +import Foundation +import os +import TableProPluginKit + +private let queryExecutorLog = Logger(subsystem: "com.TablePro", category: "QueryExecutor") + +struct QueryFetchResult { + let columns: [String] + let columnTypes: [ColumnType] + let rows: [[String?]] + let executionTime: TimeInterval + let rowsAffected: Int + let statusMessage: String? + let isTruncated: Bool +} + +typealias SchemaResult = (columnInfo: [ColumnInfo], fkInfo: [ForeignKeyInfo], approximateRowCount: Int?) + +struct ParsedSchemaMetadata { + let columnDefaults: [String: String?] + let columnForeignKeys: [String: ForeignKeyInfo] + let columnNullable: [String: Bool] + let primaryKeyColumns: [String] + let approximateRowCount: Int? + let columnEnumValues: [String: [String]] +} + +struct QueryExecutionResult { + let fetchResult: QueryFetchResult + let schemaResult: SchemaResult? + let parsedMetadata: ParsedSchemaMetadata? +} + +@MainActor +final class QueryExecutor { + let connection: DatabaseConnection + var connectionId: UUID { connection.id } + + init(connection: DatabaseConnection) { + self.connection = connection + } + + // MARK: - Driver access + + private func resolveDriver() throws -> DatabaseDriver { + guard let driver = DatabaseManager.shared.driver(for: connectionId) else { + throw DatabaseError.notConnected + } + return driver + } + + // MARK: - Public orchestrators + + func executeQuery( + sql: String, + parameters: [Any?]? = nil, + rowCap: Int?, + tableName: String?, + fetchSchemaForTable: Bool + ) async throws -> QueryExecutionResult { + let connId = connectionId + + var parallelSchemaTask: Task? + if fetchSchemaForTable, let tableName, !tableName.isEmpty { + parallelSchemaTask = Task { + guard let driver = DatabaseManager.shared.driver(for: connId) else { + throw DatabaseError.notConnected + } + async let cols = driver.fetchColumns(table: tableName) + async let fks = driver.fetchForeignKeys(table: tableName) + let result = try await (columnInfo: cols, fkInfo: fks) + let approxCount = try? await driver.fetchApproximateRowCount(table: tableName) + return ( + columnInfo: result.columnInfo, + fkInfo: result.fkInfo, + approximateRowCount: approxCount + ) + } + } + + let driver = try resolveDriver() + + let fetchResult: QueryFetchResult + do { + if let parameters { + fetchResult = try await Self.fetchQueryDataParameterized( + driver: driver, + sql: sql, + parameters: parameters, + rowCap: rowCap + ) + } else { + fetchResult = try await Self.fetchQueryData( + driver: driver, + sql: sql, + rowCap: rowCap + ) + } + } catch { + parallelSchemaTask?.cancel() + throw error + } + + var schemaResult: SchemaResult? + if fetchSchemaForTable, let tableName, !tableName.isEmpty { + schemaResult = await Self.awaitSchemaResult( + connectionId: connId, + parallelTask: parallelSchemaTask, + tableName: tableName + ) + } + + let parsedMetadata = schemaResult.map { Self.parseSchemaMetadata($0) } + + return QueryExecutionResult( + fetchResult: fetchResult, + schemaResult: schemaResult, + parsedMetadata: parsedMetadata + ) + } + + // MARK: - Driver fetch (nonisolated, runs on background) + + nonisolated static func fetchQueryData( + driver: DatabaseDriver, + sql: String, + rowCap: Int? + ) async throws -> QueryFetchResult { + let start = CFAbsoluteTimeGetCurrent() + queryExecutorLog.info("[executeUserQuery] sql=\(sql.prefix(100), privacy: .public) rowCap=\(rowCap?.description ?? "nil")") + let result = try await driver.executeUserQuery(query: sql, rowCap: rowCap, parameters: nil) + let elapsed = CFAbsoluteTimeGetCurrent() - start + queryExecutorLog.info("[executeUserQuery] rows=\(result.rows.count) truncated=\(result.isTruncated) driverTime=\(String(format: "%.3f", result.executionTime))s totalTime=\(String(format: "%.3f", elapsed))s") + return QueryFetchResult( + columns: result.columns, + columnTypes: result.columnTypes, + rows: result.rows, + executionTime: result.executionTime, + rowsAffected: result.rowsAffected, + statusMessage: result.statusMessage, + isTruncated: result.isTruncated + ) + } + + nonisolated static func fetchQueryDataParameterized( + driver: DatabaseDriver, + sql: String, + parameters: [Any?], + rowCap: Int? + ) async throws -> QueryFetchResult { + let start = CFAbsoluteTimeGetCurrent() + queryExecutorLog.info("[executeUserQueryParameterized] sql=\(sql.prefix(100), privacy: .public) rowCap=\(rowCap?.description ?? "nil") params=\(parameters.count)") + let result = try await driver.executeUserQuery(query: sql, rowCap: rowCap, parameters: parameters) + let elapsed = CFAbsoluteTimeGetCurrent() - start + queryExecutorLog.info("[executeUserQueryParameterized] rows=\(result.rows.count) truncated=\(result.isTruncated) driverTime=\(String(format: "%.3f", result.executionTime))s totalTime=\(String(format: "%.3f", elapsed))s") + return QueryFetchResult( + columns: result.columns, + columnTypes: result.columnTypes, + rows: result.rows, + executionTime: result.executionTime, + rowsAffected: result.rowsAffected, + statusMessage: result.statusMessage, + isTruncated: result.isTruncated + ) + } + + // MARK: - Schema await + parse + + static func awaitSchemaResult( + connectionId: UUID, + parallelTask: Task?, + tableName: String + ) async -> SchemaResult? { + if let parallelTask { + return try? await parallelTask.value + } + guard let driver = DatabaseManager.shared.driver(for: connectionId) else { return nil } + do { + async let cols = driver.fetchColumns(table: tableName) + async let fks = driver.fetchForeignKeys(table: tableName) + let (c, f) = try await (cols, fks) + let approxCount = try? await driver.fetchApproximateRowCount(table: tableName) + return (columnInfo: c, fkInfo: f, approximateRowCount: approxCount) + } catch { + queryExecutorLog.error("Phase 2 schema fetch failed: \(error.localizedDescription, privacy: .public)") + return nil + } + } + + static func parseSchemaMetadata(_ schema: SchemaResult) -> ParsedSchemaMetadata { + var defaults: [String: String?] = [:] + var fks: [String: ForeignKeyInfo] = [:] + var nullable: [String: Bool] = [:] + for col in schema.columnInfo { + defaults[col.name] = col.defaultValue + nullable[col.name] = col.isNullable + } + for fk in schema.fkInfo { + fks[fk.column] = fk + } + var enumValues: [String: [String]] = [:] + for col in schema.columnInfo { + if let values = ColumnType.parseEnumValues(from: col.dataType) { + enumValues[col.name] = values + } else if let values = ColumnType.parseClickHouseEnumValues(from: col.dataType) { + enumValues[col.name] = values + } + } + return ParsedSchemaMetadata( + columnDefaults: defaults, + columnForeignKeys: fks, + columnNullable: nullable, + primaryKeyColumns: schema.columnInfo.filter { $0.isPrimaryKey }.map(\.name), + approximateRowCount: schema.approximateRowCount, + columnEnumValues: enumValues + ) + } + + // MARK: - Row cap policy + + static func resolveRowCap(sql: String, tabType: TabType, databaseType: DatabaseType) -> Int? { + let dataGridSettings = AppSettingsManager.shared.dataGrid + let trimmedUpper = sql.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + let isSelectQuery = trimmedUpper.hasPrefix("SELECT ") || trimmedUpper.hasPrefix("WITH ") + let isWrite = QueryClassifier.isWriteQuery(sql, databaseType: databaseType) + let isDDL = isDDLStatement(sql) + + guard tabType == .query, isSelectQuery, !isWrite, !isDDL, + dataGridSettings.truncateQueryResults + else { + return nil + } + return dataGridSettings.validatedQueryResultRowCap + } + + private static let ddlPrefixes: [String] = [ + "CREATE", "DROP", "ALTER", "TRUNCATE", "RENAME", + ] + + static func isDDLStatement(_ sql: String) -> Bool { + let trimmed = sql.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + return ddlPrefixes.contains { trimmed.hasPrefix($0) } + } + + // MARK: - Parameter detection + + static func detectAndReconcileParameters( + sql: String, + existing: [QueryParameter] + ) -> [QueryParameter] { + let detectedNames = SQLParameterExtractor.extractParameters(from: sql) + guard !detectedNames.isEmpty else { return [] } + + let existingByName = Dictionary( + existing.map { ($0.name, $0) }, + uniquingKeysWith: { first, _ in first } + ) + + return detectedNames.map { name in + if let existing = existingByName[name] { + return existing + } + return QueryParameter(name: name) + } + } +} diff --git a/TablePro/Core/Services/Query/QuerySqlParser.swift b/TablePro/Core/Services/Query/QuerySqlParser.swift new file mode 100644 index 000000000..1a677f41f --- /dev/null +++ b/TablePro/Core/Services/Query/QuerySqlParser.swift @@ -0,0 +1,75 @@ +import Foundation + +enum QuerySqlParser { + private static let tableNameRegex = try? NSRegularExpression( + pattern: #"(?i)^\s*SELECT\s+.+?\s+FROM\s+(?:\[([^\]]+)\]|[`"]([^`"]+)[`"]|([\w$]+))\s*(?:WHERE|ORDER|LIMIT|GROUP|HAVING|OFFSET|FETCH|$|;)"#, + options: [] + ) + + private static let mongoCollectionRegex = try? NSRegularExpression( + pattern: #"^\s*db\.(\w+)\."#, + options: [] + ) + + private static let mongoBracketCollectionRegex = try? NSRegularExpression( + pattern: #"^\s*db\["([^"]+)"\]"#, + options: [] + ) + + static func extractTableName(from sql: String) -> String? { + let nsRange = NSRange(sql.startIndex..., in: sql) + + if let regex = tableNameRegex, + let match = regex.firstMatch(in: sql, options: [], range: nsRange) { + for group in 1...3 { + let r = match.range(at: group) + if r.location != NSNotFound, let range = Range(r, in: sql) { + return String(sql[range]) + } + } + } + + if let regex = mongoBracketCollectionRegex, + let match = regex.firstMatch(in: sql, options: [], range: nsRange), + let range = Range(match.range(at: 1), in: sql) { + return String(sql[range]) + } + + if let regex = mongoCollectionRegex, + let match = regex.firstMatch(in: sql, options: [], range: nsRange), + let range = Range(match.range(at: 1), in: sql) { + return String(sql[range]) + } + + return nil + } + + static func stripTrailingOrderBy(from sql: String) -> String { + let trimmed = sql.trimmingCharacters(in: .whitespacesAndNewlines) + let nsString = trimmed as NSString + let pattern = "\\s+ORDER\\s+BY\\s+(?![^(]*\\))[^)]*$" + guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) else { + return trimmed + } + let range = NSRange(location: 0, length: nsString.length) + return regex.stringByReplacingMatches(in: trimmed, range: range, withTemplate: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + static func parseSQLiteCheckConstraintValues(createSQL: String, columnName: String) -> [String]? { + let escapedName = NSRegularExpression.escapedPattern(for: columnName) + let pattern = "CHECK\\s*\\(\\s*\"?\(escapedName)\"?\\s+IN\\s*\\(([^)]+)\\)\\s*\\)" + guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) else { + return nil + } + let nsString = createSQL as NSString + guard let match = regex.firstMatch( + in: createSQL, + range: NSRange(location: 0, length: nsString.length) + ), match.numberOfRanges > 1 else { + return nil + } + let valuesString = nsString.substring(with: match.range(at: 1)) + return ColumnType.parseEnumValues(from: "ENUM(\(valuesString))") + } +} diff --git a/TablePro/Core/Services/Query/TableRowsStore.swift b/TablePro/Core/Services/Query/TableRowsStore.swift deleted file mode 100644 index 94be10cf5..000000000 --- a/TablePro/Core/Services/Query/TableRowsStore.swift +++ /dev/null @@ -1,65 +0,0 @@ -import Foundation - -@MainActor -@Observable -final class TableRowsStore { - @ObservationIgnored private var store: [UUID: TableRows] = [:] - @ObservationIgnored private var evictedSet: Set = [] - - func tableRows(for tabId: UUID) -> TableRows { - if let existing = store[tabId] { - return existing - } - let rows = TableRows() - store[tabId] = rows - return rows - } - - func existingTableRows(for tabId: UUID) -> TableRows? { - store[tabId] - } - - func setTableRows(_ rows: TableRows, for tabId: UUID) { - store[tabId] = rows - evictedSet.remove(tabId) - } - - func updateTableRows(for tabId: UUID, _ mutate: (inout TableRows) -> Void) { - var rows = store[tabId] ?? TableRows() - mutate(&rows) - store[tabId] = rows - evictedSet.remove(tabId) - } - - func removeTableRows(for tabId: UUID) { - store.removeValue(forKey: tabId) - evictedSet.remove(tabId) - } - - func isEvicted(_ tabId: UUID) -> Bool { - evictedSet.contains(tabId) - } - - func evict(for tabId: UUID) { - guard var rows = store[tabId] else { return } - guard !rows.rows.isEmpty else { return } - rows.rows = [] - store[tabId] = rows - evictedSet.insert(tabId) - } - - func evictAll(except activeTabId: UUID?) { - for (id, rows) in store where id != activeTabId { - guard !rows.rows.isEmpty, !evictedSet.contains(id) else { continue } - var copy = rows - copy.rows = [] - store[id] = copy - evictedSet.insert(id) - } - } - - func tearDown() { - store.removeAll() - evictedSet.removeAll() - } -} diff --git a/TablePro/Core/Storage/ColumnVisibilityPersistence.swift b/TablePro/Core/Storage/ColumnVisibilityPersistence.swift new file mode 100644 index 000000000..6499626a8 --- /dev/null +++ b/TablePro/Core/Storage/ColumnVisibilityPersistence.swift @@ -0,0 +1,32 @@ +// +// ColumnVisibilityPersistence.swift +// TablePro +// + +import Foundation + +enum ColumnVisibilityPersistence { + static func key(tableName: String, connectionId: UUID) -> String { + "com.TablePro.columns.hiddenColumns.\(connectionId.uuidString).\(tableName)" + } + + static func loadHiddenColumns( + for tableName: String, + connectionId: UUID, + defaults: UserDefaults = .standard + ) -> Set { + let storageKey = key(tableName: tableName, connectionId: connectionId) + guard let array = defaults.stringArray(forKey: storageKey) else { return [] } + return Set(array) + } + + static func saveHiddenColumns( + _ hiddenColumns: Set, + for tableName: String, + connectionId: UUID, + defaults: UserDefaults = .standard + ) { + let storageKey = key(tableName: tableName, connectionId: connectionId) + defaults.set(Array(hiddenColumns), forKey: storageKey) + } +} diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index aa57a3587..d9eacf564 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -30,6 +30,7 @@ struct QueryTab: Identifiable, Equatable { var schemaVersion: Int var metadataVersion: Int var paginationVersion: Int + var loadEpoch: Int = 0 init( id: UUID = UUID(), @@ -56,6 +57,7 @@ struct QueryTab: Identifiable, Equatable { self.schemaVersion = 0 self.metadataVersion = 0 self.paginationVersion = 0 + self.loadEpoch = 0 } init(from persisted: PersistedTab) { @@ -87,6 +89,7 @@ struct QueryTab: Identifiable, Equatable { self.schemaVersion = 0 self.metadataVersion = 0 self.paginationVersion = 0 + self.loadEpoch = 0 } @MainActor static func buildBaseTableQuery( @@ -166,5 +169,6 @@ struct QueryTab: Identifiable, Equatable { && lhs.tabType == rhs.tabType && lhs.isPreview == rhs.isPreview && lhs.hasUserInteraction == rhs.hasUserInteraction + && lhs.loadEpoch == rhs.loadEpoch } } diff --git a/TablePro/Models/Query/QueryTabManager.swift b/TablePro/Models/Query/QueryTabManager.swift index 0f6d387c5..f23d03c3f 100644 --- a/TablePro/Models/Query/QueryTabManager.swift +++ b/TablePro/Models/Query/QueryTabManager.swift @@ -16,6 +16,7 @@ final class QueryTabManager { if oldValue.map(\.id) != tabs.map(\.id) { tabStructureVersion += 1 } + syncTabSessionRegistry(oldTabs: oldValue, newTabs: tabs) } } @@ -27,9 +28,28 @@ final class QueryTabManager { @ObservationIgnored private var _tabIndexMapDirty = true @ObservationIgnored private let globalTabsProvider: () -> [QueryTab] + @ObservationIgnored private weak var tabSessionRegistry: TabSessionRegistry? - init(globalTabsProvider: @escaping () -> [QueryTab] = { [] }) { + init( + globalTabsProvider: @escaping () -> [QueryTab] = { [] }, + tabSessionRegistry: TabSessionRegistry? = nil + ) { self.globalTabsProvider = globalTabsProvider + self.tabSessionRegistry = tabSessionRegistry + } + + private func syncTabSessionRegistry(oldTabs: [QueryTab], newTabs: [QueryTab]) { + guard let registry = tabSessionRegistry else { return } + let oldIds = Set(oldTabs.map(\.id)) + let newIds = Set(newTabs.map(\.id)) + for removedId in oldIds.subtracting(newIds) { + registry.unregister(id: removedId) + } + for addedTab in newTabs where !oldIds.contains(addedTab.id) { + if registry.session(for: addedTab.id) == nil { + registry.register(TabSession(queryTab: addedTab)) + } + } } private func rebuildTabIndexMapIfNeeded() { diff --git a/TablePro/Models/Query/TabSession.swift b/TablePro/Models/Query/TabSession.swift new file mode 100644 index 000000000..528999338 --- /dev/null +++ b/TablePro/Models/Query/TabSession.swift @@ -0,0 +1,208 @@ +// +// TabSession.swift +// TablePro +// +// Foundation type for the tab/window subsystem rewrite. +// See docs/architecture/tab-subsystem-rewrite.md for the full design. +// + +import Foundation +import Observation + +/// Per-tab state container for the editor tab/window subsystem. +/// +/// `QueryTab` (struct) is the persistence shape and the canonical source of +/// truth for per-tab state. `TabSession` (this class) is the @Observable +/// reference-type mirror that SwiftUI views read from for fine-grained +/// updates. They are kept in sync by the coordinator helpers in +/// `MainContentCoordinator+FilterState`, `+ColumnVisibility`, and +/// `QueryTabManager.tabs.didSet` (which registers a session on tab insert +/// and unregisters on remove). +/// +/// **Invariant**: every `tabManager.tabs[index]` has exactly one `TabSession` +/// in `TabSessionRegistry`, keyed by the same `id`. Mutations to per-tab +/// state must go through the coordinator helpers — direct writes to +/// `tabManager.tabs[index].field = …` will desync the session mirror until +/// the next coordinator-driven mutation re-syncs. +/// +/// Class (not struct) because `@Observable` requires a reference type; +/// SwiftUI's Observation framework tracks property accesses on observed +/// instances. Session-only fields (`tableRows`, `isEvicted`) are not part +/// of the `QueryTab` ↔ `TabSession` mirror because they aren't persisted. +@Observable @MainActor +final class TabSession: Identifiable { + // MARK: - Identity + + let id: UUID + + // MARK: - Tab metadata + + var title: String + var tabType: TabType + var isPreview: Bool + + // MARK: - Content + + var content: TabQueryContent + + // MARK: - Execution + + var execution: TabExecutionState + + // MARK: - Table context + + var tableContext: TabTableContext + + // MARK: - Display + + var display: TabDisplayState + + // MARK: - Per-tab UI state + + var pendingChanges: TabChangeSnapshot + var selectedRowIndices: Set + var sortState: SortState + var filterState: TabFilterState + var columnLayout: ColumnLayoutState + var pagination: PaginationState + + // MARK: - Tracking + + var hasUserInteraction: Bool + var schemaVersion: Int + var metadataVersion: Int + var paginationVersion: Int + var loadEpoch: Int + + // MARK: - Session-only state + + var tableRows: TableRows + var isEvicted: Bool + + // MARK: - Init + + /// Lift a `QueryTab` value into a `TabSession` reference. Used at the + /// boundary between legacy code paths (which still pass `QueryTab` by + /// value) and the new architecture (which holds `TabSession` references). + init(queryTab: QueryTab) { + self.id = queryTab.id + self.title = queryTab.title + self.tabType = queryTab.tabType + self.isPreview = queryTab.isPreview + self.content = queryTab.content + self.execution = queryTab.execution + self.tableContext = queryTab.tableContext + self.display = queryTab.display + self.pendingChanges = queryTab.pendingChanges + self.selectedRowIndices = queryTab.selectedRowIndices + self.sortState = queryTab.sortState + self.filterState = queryTab.filterState + self.columnLayout = queryTab.columnLayout + self.pagination = queryTab.pagination + self.hasUserInteraction = queryTab.hasUserInteraction + self.schemaVersion = queryTab.schemaVersion + self.metadataVersion = queryTab.metadataVersion + self.paginationVersion = queryTab.paginationVersion + self.loadEpoch = queryTab.loadEpoch + self.tableRows = TableRows() + self.isEvicted = false + } + + /// Build a `TabSession` from primitive parameters, mirroring `QueryTab.init`. + /// Used by callers that construct sessions directly without an intermediate + /// `QueryTab` value. + init( + id: UUID = UUID(), + title: String = "Query", + query: String = "", + tabType: TabType = .query, + tableName: String? = nil + ) { + self.id = id + self.title = title + self.tabType = tabType + self.isPreview = false + self.content = TabQueryContent(query: query) + self.execution = TabExecutionState() + self.tableContext = TabTableContext(tableName: tableName, isEditable: tabType == .table) + self.display = TabDisplayState() + self.pendingChanges = TabChangeSnapshot() + self.selectedRowIndices = [] + self.sortState = SortState() + self.filterState = TabFilterState() + self.columnLayout = ColumnLayoutState() + self.pagination = PaginationState() + self.hasUserInteraction = false + self.schemaVersion = 0 + self.metadataVersion = 0 + self.paginationVersion = 0 + self.loadEpoch = 0 + self.tableRows = TableRows() + self.isEvicted = false + } + + // MARK: - Conversion + + /// Snapshot the current session state back into a `QueryTab` value. Used + /// by code paths that haven't migrated yet (persistence, legacy stores). + /// Pure read; callers can mutate the returned struct without affecting + /// the session. + func snapshot() -> QueryTab { + var tab = QueryTab( + id: id, + title: title, + query: content.query, + tabType: tabType, + tableName: tableContext.tableName + ) + tab.isPreview = isPreview + tab.content = content + tab.execution = execution + tab.tableContext = tableContext + tab.display = display + tab.pendingChanges = pendingChanges + tab.selectedRowIndices = selectedRowIndices + tab.sortState = sortState + tab.filterState = filterState + tab.columnLayout = columnLayout + tab.pagination = pagination + tab.hasUserInteraction = hasUserInteraction + tab.schemaVersion = schemaVersion + tab.metadataVersion = metadataVersion + tab.paginationVersion = paginationVersion + tab.loadEpoch = loadEpoch + return tab + } + + /// Replace the session's state from a `QueryTab` value, preserving the + /// session's identity (`id`). Used when a tab's persisted state is + /// reloaded from disk and the existing session must absorb the new state + /// without observers losing track of the instance. + /// + /// Session-only fields (`tableRows`, `isEvicted`) are intentionally NOT + /// touched — they aren't part of the `QueryTab` shape and are repopulated + /// by the next lazy-load. Callers wanting to discard cached row data + /// should set `tabSessionRegistry.session(for: id)?.tableRows = .init()` + /// (or call `removeTableRows`) explicitly before `absorb`. + func absorb(_ queryTab: QueryTab) { + precondition(queryTab.id == id, "TabSession.absorb requires matching ids") + self.title = queryTab.title + self.tabType = queryTab.tabType + self.isPreview = queryTab.isPreview + self.content = queryTab.content + self.execution = queryTab.execution + self.tableContext = queryTab.tableContext + self.display = queryTab.display + self.pendingChanges = queryTab.pendingChanges + self.selectedRowIndices = queryTab.selectedRowIndices + self.sortState = queryTab.sortState + self.filterState = queryTab.filterState + self.columnLayout = queryTab.columnLayout + self.pagination = queryTab.pagination + self.hasUserInteraction = queryTab.hasUserInteraction + self.schemaVersion = queryTab.schemaVersion + self.metadataVersion = queryTab.metadataVersion + self.paginationVersion = queryTab.paginationVersion + self.loadEpoch = queryTab.loadEpoch + } +} diff --git a/TablePro/Models/Query/TabSessionRegistry.swift b/TablePro/Models/Query/TabSessionRegistry.swift new file mode 100644 index 000000000..78e026626 --- /dev/null +++ b/TablePro/Models/Query/TabSessionRegistry.swift @@ -0,0 +1,101 @@ +// +// TabSessionRegistry.swift +// TablePro +// + +import Foundation + +@MainActor +final class TabSessionRegistry { + private var sessions: [UUID: TabSession] = [:] + + func session(for id: UUID) -> TabSession? { + sessions[id] + } + + func register(_ session: TabSession) { + sessions[session.id] = session + } + + func unregister(id: UUID) { + sessions.removeValue(forKey: id) + } + + func removeAll() { + sessions.removeAll() + } + + var allSessions: [TabSession] { + Array(sessions.values) + } + + // MARK: - Row data access + + func tableRows(for tabId: UUID) -> TableRows { + sessions[tabId]?.tableRows ?? TableRows() + } + + func existingTableRows(for tabId: UUID) -> TableRows? { + guard let session = sessions[tabId] else { return nil } + guard !session.tableRows.rows.isEmpty || !session.tableRows.columns.isEmpty else { return nil } + return session.tableRows + } + + func setTableRows(_ rows: TableRows, for tabId: UUID) { + let session = ensureSession(for: tabId) + session.tableRows = rows + session.isEvicted = false + } + + func updateTableRows(for tabId: UUID, _ mutate: (inout TableRows) -> Void) { + let session = ensureSession(for: tabId) + var rows = session.tableRows + mutate(&rows) + session.tableRows = rows + session.isEvicted = false + } + + func removeTableRows(for tabId: UUID) { + guard let session = sessions[tabId] else { return } + session.tableRows = TableRows() + session.isEvicted = false + } + + func isEvicted(_ tabId: UUID) -> Bool { + sessions[tabId]?.isEvicted ?? false + } + + /// Evict row data for a tab. Sets `isEvicted = true` and bumps `loadEpoch` + /// so SwiftUI's `.task(id:)` lazy-load re-fires. + /// + /// Returns early if the session has no rows to evict — calling `evict` on + /// a tab with empty rows is a no-op (no `isEvicted` change, no epoch bump), + /// matching the original `TableRowsStore.evict` semantics. Use + /// `tabSessionRegistry.session(for:)?.isEvicted = true` directly if you + /// need to mark a fresh-but-empty session as evicted. + func evict(for tabId: UUID) { + guard let session = sessions[tabId] else { return } + guard !session.tableRows.rows.isEmpty else { return } + session.tableRows.rows = [] + session.isEvicted = true + session.loadEpoch &+= 1 + } + + func evictAll(except activeTabId: UUID?) { + for session in sessions.values where session.id != activeTabId { + guard !session.tableRows.rows.isEmpty, !session.isEvicted else { continue } + session.tableRows.rows = [] + session.isEvicted = true + session.loadEpoch &+= 1 + } + } + + private func ensureSession(for tabId: UUID) -> TabSession { + if let existing = sessions[tabId] { + return existing + } + let session = TabSession(id: tabId) + sessions[tabId] = session + return session + } +} diff --git a/TablePro/Models/UI/ColumnVisibilityManager.swift b/TablePro/Models/UI/ColumnVisibilityManager.swift deleted file mode 100644 index 05924380d..000000000 --- a/TablePro/Models/UI/ColumnVisibilityManager.swift +++ /dev/null @@ -1,83 +0,0 @@ -// -// ColumnVisibilityManager.swift -// TablePro -// - -import Foundation -import Observation - -@MainActor @Observable -internal final class ColumnVisibilityManager { - private(set) var hiddenColumns: Set = [] - - var hasHiddenColumns: Bool { - !hiddenColumns.isEmpty - } - - var hiddenCount: Int { - hiddenColumns.count - } - - func toggleColumn(_ columnName: String) { - if hiddenColumns.contains(columnName) { - hiddenColumns.remove(columnName) - } else { - hiddenColumns.insert(columnName) - } - } - - func hideColumn(_ columnName: String) { - hiddenColumns.insert(columnName) - } - - func showColumn(_ columnName: String) { - hiddenColumns.remove(columnName) - } - - func showAll() { - hiddenColumns.removeAll() - } - - func hideAll(_ columns: [String]) { - hiddenColumns = Set(columns) - } - - // MARK: - Per-Tab Persistence - - func saveToColumnLayout() -> Set { - hiddenColumns - } - - func restoreFromColumnLayout(_ columns: Set) { - hiddenColumns = columns - } - - // MARK: - Per-Table UserDefaults Persistence - - func saveLastHiddenColumns(for tableName: String, connectionId: UUID) { - let key = Self.userDefaultsKey(tableName: tableName, connectionId: connectionId) - let array = Array(hiddenColumns) - UserDefaults.standard.set(array, forKey: key) - } - - func restoreLastHiddenColumns(for tableName: String, connectionId: UUID) { - let key = Self.userDefaultsKey(tableName: tableName, connectionId: connectionId) - if let array = UserDefaults.standard.stringArray(forKey: key) { - hiddenColumns = Set(array) - } else { - hiddenColumns = [] - } - } - - /// Remove hidden column names that no longer exist in the current result set - func pruneStaleColumns(_ currentColumns: [String]) { - let currentSet = Set(currentColumns) - hiddenColumns = hiddenColumns.intersection(currentSet) - } - - // MARK: - Private - - private static func userDefaultsKey(tableName: String, connectionId: UUID) -> String { - "com.TablePro.columns.hiddenColumns.\(connectionId.uuidString).\(tableName)" - } -} diff --git a/TablePro/Models/UI/FilterState.swift b/TablePro/Models/UI/FilterState.swift index 55305d241..dc7a9dce7 100644 --- a/TablePro/Models/UI/FilterState.swift +++ b/TablePro/Models/UI/FilterState.swift @@ -2,14 +2,9 @@ // FilterState.swift // TablePro // -// Manages the state of the filter panel -// import Foundation -import Observation -import SwiftUI -/// Filter logic mode for combining multiple filters enum FilterLogicMode: String, Codable { case and = "AND" case or = "OR" @@ -19,327 +14,6 @@ enum FilterLogicMode: String, Codable { } } -/// Observable state manager for filter panel -@MainActor @Observable -final class FilterStateManager { - var filters: [TableFilter] = [] - var isVisible: Bool = false - var appliedFilters: [TableFilter] = [] - var filterLogicMode: FilterLogicMode = .and - - /// Settings storage reference - private let settingsStorage = FilterSettingsStorage.shared - private let presetStorage = FilterPresetStorage.shared - - // MARK: - Filter Management - - /// Add a new empty filter with default settings - func addFilter(columns: [String] = [], primaryKeyColumn: String? = nil) { - let settings = settingsStorage.loadSettings() - var newFilter = TableFilter() - - // Apply default column setting - switch settings.defaultColumn { - case .rawSQL: - newFilter.columnName = TableFilter.rawSQLColumn - case .primaryKey: - if let pk = primaryKeyColumn { - newFilter.columnName = pk - } else if let firstColumn = columns.first { - newFilter.columnName = firstColumn - } - case .anyColumn: - if let firstColumn = columns.first { - newFilter.columnName = firstColumn - } - } - - // Apply default operator setting - newFilter.filterOperator = settings.defaultOperator.toFilterOperator() - - // New filters should be selected by default for "Apply All" - newFilter.isSelected = true - - filters.append(newFilter) - } - - /// Add a new filter with a specific column pre-selected (for context menu "Filter with column") - func addFilterForColumn(_ columnName: String) { - let settings = settingsStorage.loadSettings() - var newFilter = TableFilter() - - // Set the specified column - newFilter.columnName = columnName - - // Apply default operator setting - newFilter.filterOperator = settings.defaultOperator.toFilterOperator() - - // New filters should be selected by default for "Apply All" - newFilter.isSelected = true - - filters.append(newFilter) - - // Show panel if hidden - if !isVisible { - show() - } - } - - /// Set a single FK navigation filter, replacing all existing state. - /// Used by FK navigation to apply an equality filter for the referenced column. - func setFKFilter(_ filter: TableFilter) { - filters = [filter] - appliedFilters = [filter] - isVisible = true - filterLogicMode = .and - } - - /// Duplicate a filter - func duplicateFilter(_ filter: TableFilter) { - var copy = filter - copy = TableFilter( - id: UUID(), - columnName: filter.columnName, - filterOperator: filter.filterOperator, - value: filter.value, - secondValue: filter.secondValue, - isSelected: true, - isEnabled: filter.isEnabled, - rawSQL: filter.rawSQL - ) - - if let index = filters.firstIndex(where: { $0.id == filter.id }) { - filters.insert(copy, at: index + 1) - } else { - filters.append(copy) - } - } - - /// Remove a filter - func removeFilter(_ filter: TableFilter) { - filters.removeAll { $0.id == filter.id } - - // Also remove from applied filters if it was applied - appliedFilters.removeAll { $0.id == filter.id } - } - - /// Update a filter - func updateFilter(_ filter: TableFilter) { - if let index = filters.firstIndex(where: { $0.id == filter.id }) { - filters[index] = filter - } - } - - /// Get binding for a filter - func binding(for filter: TableFilter) -> Binding { - Binding( - get: { [weak self] in - self?.filters.first { $0.id == filter.id } ?? filter - }, - set: { [weak self] newValue in - self?.updateFilter(newValue) - } - ) - } - - // MARK: - Apply Filters - - /// Apply a single filter - func applySingleFilter(_ filter: TableFilter) { - guard filter.isValid else { return } - filters = [filter] - appliedFilters = [filter] - isVisible = true - } - - /// Apply all selected filters - func applySelectedFilters() { - appliedFilters = filters.filter { $0.isSelected && $0.isValid } - } - - /// Apply all valid enabled filters - func applyAllFilters() { - appliedFilters = filters.filter { $0.isEnabled && $0.isValid } - } - - /// Clear all applied filters (unset) - func clearAppliedFilters() { - appliedFilters = [] - } - - // MARK: - Panel Visibility - - /// Toggle filter panel visibility - func toggle() { - withAnimation(.easeInOut(duration: 0.15)) { - isVisible.toggle() - } - } - - /// Show panel - func show() { - withAnimation(.easeInOut(duration: 0.15)) { - isVisible = true - } - } - - /// Close panel - func close() { - withAnimation(.easeInOut(duration: 0.15)) { - isVisible = false - } - } - - // MARK: - Selection - - /// Select/deselect all filters - func selectAll(_ selected: Bool) { - var updated = filters - for i in 0.. TabFilterState { - TabFilterState( - filters: filters, - appliedFilters: appliedFilters, - isVisible: isVisible, - filterLogicMode: filterLogicMode - ) - } - - /// Restore filter state from tab - func restoreFromTabState(_ state: TabFilterState) { - filters = state.filters - appliedFilters = state.appliedFilters - isVisible = state.isVisible - filterLogicMode = state.filterLogicMode - } - - /// Save filters for a table (for "Restore Last Filter" setting) - func saveLastFilters(for tableName: String) { - settingsStorage.saveLastFilters(appliedFilters, for: tableName) - } - - /// Restore last filters for a table - func restoreLastFilters(for tableName: String) { - let settings = settingsStorage.loadSettings() - if settings.panelState == .restoreLast { - let restored = settingsStorage.loadLastFilters(for: tableName) - if !restored.isEmpty { - filters = restored - appliedFilters = restored - } - } - if settings.panelState == .alwaysShow { - isVisible = true - } - } - - /// Clear all filters - func clearAll() { - isVisible = false - filters = [] - appliedFilters = [] - } - - // MARK: - Filter Presets - - /// Save current filters as a named preset - func saveAsPreset(name: String) { - let preset = FilterPreset(name: name, filters: filters) - presetStorage.savePreset(preset) - } - - /// Load filters from a preset - func loadPreset(_ preset: FilterPreset) { - filters = preset.filters - } - - /// Get all saved presets - func loadAllPresets() -> [FilterPreset] { - presetStorage.loadAllPresets() - } - - /// Delete a preset - func deletePreset(_ preset: FilterPreset) { - presetStorage.deletePreset(preset) - } - - // MARK: - SQL Generation - - /// Generate preview SQL for the "SQL" button - /// Uses selected filters if any are selected, otherwise uses all valid filters - func generatePreviewSQL(databaseType: DatabaseType) -> String { - guard let dialect = PluginManager.shared.sqlDialect(for: databaseType) else { - return "-- Filters are applied natively" - } - let generator = FilterSQLGenerator(dialect: dialect) - let filtersToPreview = getFiltersForPreview() - - // If no valid filters but filters exist, show helpful message - if filtersToPreview.isEmpty && !filters.isEmpty { - let invalidCount = filters.count(where: { !$0.isValid }) - if invalidCount > 0 { - return "-- No valid filters to preview\n-- Complete \(invalidCount) filter(s) by:\n-- • Selecting a column\n-- • Entering a value (if required)\n-- • Filling in second value for BETWEEN" - } - } - - return generator.generateWhereClause(from: filtersToPreview, logicMode: filterLogicMode) - } - - /// Get filters to use for preview/application - /// If some (but not all) filters are selected, use only those - /// Otherwise use all valid filters (single-pass) - private func getFiltersForPreview() -> [TableFilter] { - var valid: [TableFilter] = [] - var selectedValid: [TableFilter] = [] - for filter in filters where filter.isEnabled && filter.isValid { - valid.append(filter) - if filter.isSelected { selectedValid.append(filter) } - } - // Only use selective mode when SOME (but not all) are selected - if selectedValid.count == valid.count || selectedValid.isEmpty { - return valid - } - return selectedValid - } -} - -// MARK: - TabFilterState Extension - extension TabFilterState { init(filters: [TableFilter], appliedFilters: [TableFilter], isVisible: Bool, filterLogicMode: FilterLogicMode) { self.filters = filters diff --git a/TablePro/Views/Filter/FilterPanelView.swift b/TablePro/Views/Filter/FilterPanelView.swift index 2fa25337d..1808e5bd3 100644 --- a/TablePro/Views/Filter/FilterPanelView.swift +++ b/TablePro/Views/Filter/FilterPanelView.swift @@ -6,7 +6,7 @@ import SwiftUI struct FilterPanelView: View { - @Bindable var filterState: FilterStateManager + let coordinator: MainContentCoordinator let columns: [String] let primaryKeyColumn: String? let databaseType: DatabaseType @@ -23,6 +23,10 @@ struct FilterPanelView: View { private let estimatedFilterRowHeight: CGFloat = 32 private let maxFilterListHeight: CGFloat = 200 + private var filterState: TabFilterState { + coordinator.selectedTabFilterState + } + var body: some View { VStack(spacing: 0) { filterHeader @@ -36,13 +40,13 @@ struct FilterPanelView: View { .background(Color(nsColor: .windowBackgroundColor)) .onAppear { if filterState.filters.isEmpty && !columns.isEmpty { - filterState.addFilter(columns: columns, primaryKeyColumn: primaryKeyColumn) + coordinator.addFilter(columns: columns, primaryKeyColumn: primaryKeyColumn) } focusedFilterId = filterState.filters.last?.id } .onChange(of: columns) { _, newColumns in if filterState.filters.isEmpty && !newColumns.isEmpty && filterState.isVisible { - filterState.addFilter(columns: newColumns, primaryKeyColumn: primaryKeyColumn) + coordinator.addFilter(columns: newColumns, primaryKeyColumn: primaryKeyColumn) focusedFilterId = filterState.filters.last?.id } } @@ -57,7 +61,7 @@ struct FilterPanelView: View { .font(.callout.weight(.medium)) if filterState.filters.count > 1 { - Picker("", selection: $filterState.filterLogicMode) { + Picker("", selection: coordinator.filterLogicModeBinding()) { Text("AND").tag(FilterLogicMode.and) Text("OR").tag(FilterLogicMode.or) } @@ -72,7 +76,7 @@ struct FilterPanelView: View { filterOptionsMenu Button("Unset") { - filterState.clearAll() + coordinator.clearFilterState() onUnset() } .buttonStyle(.bordered) @@ -85,7 +89,7 @@ struct FilterPanelView: View { } .buttonStyle(.borderedProminent) .controlSize(.small) - .disabled(filterState.validFilterCount == 0) + .disabled(validFilterCount == 0) .help(String(localized: "Apply filters")) } .padding(.horizontal, 12) @@ -97,7 +101,7 @@ struct FilterPanelView: View { Button("Cancel", role: .cancel) {} Button("Save") { guard !newPresetName.isEmpty else { return } - filterState.saveAsPreset(name: newPresetName) + coordinator.saveFilterPreset(name: newPresetName) } } message: { Text("Enter a name for this filter preset") @@ -107,7 +111,7 @@ struct FilterPanelView: View { private var filterOptionsMenu: some View { Menu { Button { - generatedSQL = filterState.generatePreviewSQL(databaseType: databaseType) + generatedSQL = coordinator.generateFilterPreviewSQL(databaseType: databaseType) showSQLSheet = true } label: { Label(String(localized: "Preview Query"), systemImage: "text.magnifyingglass") @@ -116,10 +120,10 @@ struct FilterPanelView: View { Divider() - let presets = filterState.loadAllPresets() + let presets = coordinator.loadAllFilterPresets() if !presets.isEmpty { ForEach(presets) { preset in - Button(action: { filterState.loadPreset(preset) }) { + Button(action: { coordinator.loadFilterPreset(preset) }) { HStack { Text(preset.name) if !presetColumnsMatch(preset) { @@ -144,7 +148,7 @@ struct FilterPanelView: View { Menu("Delete Preset") { ForEach(presets) { preset in Button(preset.name, role: .destructive) { - filterState.deletePreset(preset) + coordinator.deleteFilterPreset(preset) } } } @@ -173,26 +177,26 @@ struct FilterPanelView: View { VStack(spacing: 0) { ForEach(filterState.filters) { filter in FilterRowView( - filter: filterState.binding(for: filter), + filter: coordinator.filterBinding(for: filter), columns: columns, completions: completionItems(), onAdd: { - filterState.addFilter(columns: columns, primaryKeyColumn: primaryKeyColumn) + coordinator.addFilter(columns: columns, primaryKeyColumn: primaryKeyColumn) focusedFilterId = filterState.filters.last?.id }, onDuplicate: { - filterState.duplicateFilter(filter) + coordinator.duplicateFilter(filter) focusedFilterId = filterState.filters.last?.id }, onRemove: { let hadAppliedFilters = filterState.hasAppliedFilters - filterState.removeFilter(filter) + coordinator.removeFilter(filter) if filterState.filters.isEmpty { if hadAppliedFilters { - filterState.clearAll() + coordinator.clearFilterState() onUnset() } else { - filterState.close() + coordinator.closeFilterPanel() } } }, @@ -217,14 +221,18 @@ struct FilterPanelView: View { } } + private var validFilterCount: Int { + filterState.filters.count(where: \.isValid) + } + private func presetColumnsMatch(_ preset: FilterPreset) -> Bool { let presetColumns = preset.filters.map(\.columnName).filter { $0 != TableFilter.rawSQLColumn } return presetColumns.allSatisfy { columns.contains($0) } } private func applyAllValidFilters() { - filterState.applyAllFilters() - onApply(filterState.appliedFilters) + coordinator.applyAllFilters() + onApply(coordinator.selectedTabFilterState.appliedFilters) } private func completionItems() -> [String] { @@ -238,24 +246,3 @@ struct FilterPanelView: View { return isSQLDialect ? columns + sqlKeywords : columns } } - -#Preview("Filter Panel") { - FilterPanelView( - filterState: { - let state = FilterStateManager() - Task { @MainActor in - state.filters = [ - TableFilter(columnName: "name", filterOperator: .contains, value: "John"), - TableFilter(columnName: "age", filterOperator: .greaterThan, value: "18") - ] - } - return state - }(), - columns: ["id", "name", "age", "email"], - primaryKeyColumn: "id", - databaseType: .mysql, - onApply: { _ in }, - onUnset: { } - ) - .frame(width: 600) -} diff --git a/TablePro/Views/Main/Child/DataTabGridDelegate.swift b/TablePro/Views/Main/Child/DataTabGridDelegate.swift index a73cb4db7..4ac906cc1 100644 --- a/TablePro/Views/Main/Child/DataTabGridDelegate.swift +++ b/TablePro/Views/Main/Child/DataTabGridDelegate.swift @@ -11,7 +11,6 @@ import AppKit @MainActor final class DataTabGridDelegate: DataGridViewDelegate { weak var coordinator: MainContentCoordinator? - var columnVisibilityManager: ColumnVisibilityManager? var selectionState: GridSelectionState? @@ -92,8 +91,7 @@ final class DataTabGridDelegate: DataGridViewDelegate { } func dataGridShowAllColumns() { - columnVisibilityManager?.showAll() - coordinator?.saveColumnVisibilityToTab() + coordinator?.showAllColumns() } func dataGridEmptySpaceMenu() -> NSMenu? { diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 8d3a65698..e490a733a 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -10,14 +10,21 @@ import AppKit import CodeEditSourceEditor import SwiftUI +/// Identity for the visibility-scoped lazy-load `.task(id:)` modifier on +/// `MainEditorContentView`. Changes to either field cancel the previous +/// task and start a new one — exactly the rapid-switch coalescing semantic +/// we want for Cmd+Number tab navigation. +private struct TabLoadKey: Hashable { + let tabId: UUID? + let loadEpoch: Int +} + struct MainEditorContentView: View { // MARK: - Dependencies var tabManager: QueryTabManager var coordinator: MainContentCoordinator var changeManager: DataChangeManager - var filterStateManager: FilterStateManager - var columnVisibilityManager: ColumnVisibilityManager let connection: DatabaseConnection let windowId: UUID let connectionId: UUID @@ -119,10 +126,16 @@ struct MainEditorContentView: View { wireDataTabDelegateStableRefs() refreshDataTabDelegateMutableRefs() coordinator.dataTabDelegate = dataTabDelegate - coordinator.onTeardown = { [self] in - cachedChangeManager = nil - coordinator.dataTabDelegate = nil - } + } + .onDisappear { + cachedChangeManager = nil + coordinator.dataTabDelegate = nil + } + .task(id: TabLoadKey( + tabId: tabManager.selectedTabId, + loadEpoch: tabManager.selectedTab?.loadEpoch ?? 0 + )) { + coordinator.lazyLoadCurrentTabIfNeeded() } .onChange(of: selectionState.indices) { _, newIndices in onSelectionChange(newIndices) @@ -143,7 +156,6 @@ struct MainEditorContentView: View { private func wireDataTabDelegateStableRefs() { dataTabDelegate.coordinator = coordinator - dataTabDelegate.columnVisibilityManager = columnVisibilityManager dataTabDelegate.selectionState = selectionState dataTabDelegate.onCellEdit = onCellEdit dataTabDelegate.onSort = onSort @@ -430,9 +442,9 @@ struct MainEditorContentView: View { ) } } else { - if filterStateManager.isVisible && tab.tabType == .table { + if tab.filterState.isVisible && tab.tabType == .table { FilterPanelView( - filterState: filterStateManager, + coordinator: coordinator, columns: resolvedRows.columns, primaryKeyColumn: changeManager.primaryKeyColumn, databaseType: connection.type, @@ -444,7 +456,7 @@ struct MainEditorContentView: View { if tab.tabType == .query && !resolvedRows.columns.isEmpty && resolvedRows.rows.isEmpty && tab.execution.lastExecutedAt != nil - && !tab.execution.isExecuting && !filterStateManager.hasAppliedFilters + && !tab.execution.isExecuting && !tab.filterState.hasAppliedFilters { emptyResultView(executionTime: tab.display.activeResultSet?.executionTime ?? tab.execution.executionTime) } else { @@ -499,7 +511,7 @@ struct MainEditorContentView: View { let tabId = tab.id DataGridView( tableRowsProvider: { [coordinator] in - coordinator.tableRowsStore.existingTableRows(for: tabId) ?? TableRows() + coordinator.tabSessionRegistry.existingTableRows(for: tabId) ?? TableRows() }, tableRowsMutator: { [coordinator] mutate in coordinator.mutateActiveTableRows(for: tabId) { rows in @@ -516,7 +528,7 @@ struct MainEditorContentView: View { primaryKeyColumns: changeManager.primaryKeyColumns, tabType: tab.tabType, showRowNumbers: AppSettingsManager.shared.dataGrid.showRowNumbers, - hiddenColumns: columnVisibilityManager.hiddenColumns + hiddenColumns: tab.columnLayout.hiddenColumns ), sortedIDs: sortedIDsForTab(tab), displayFormats: displayFormats(for: tab), @@ -532,7 +544,7 @@ struct MainEditorContentView: View { } private func resolvedTableRows(for tab: QueryTab) -> TableRows { - coordinator.tableRowsStore.existingTableRows(for: tab.id) ?? TableRows() + coordinator.tabSessionRegistry.existingTableRows(for: tab.id) ?? TableRows() } private func displayFormats(for tab: QueryTab) -> [ValueDisplayFormat?] { @@ -548,7 +560,7 @@ struct MainEditorContentView: View { return cached.formats } - let tableRows = coordinator.tableRowsStore.existingTableRows(for: tab.id) + let tableRows = coordinator.tabSessionRegistry.existingTableRows(for: tab.id) let columns = tableRows?.columns ?? [] let columnTypes = tableRows?.columnTypes ?? [] guard !columns.isEmpty else { return [] } @@ -694,8 +706,8 @@ struct MainEditorContentView: View { let resolvedRows = resolvedTableRows(for: tab) return MainStatusBarView( snapshot: StatusBarSnapshot(tab: tab, tableRows: resolvedRows), - filterStateManager: filterStateManager, - columnVisibilityManager: columnVisibilityManager, + filterState: tab.filterState, + hiddenColumns: tab.columnLayout.hiddenColumns, allColumns: resolvedRows.columns, selectedRowIndices: selectionState.indices, viewMode: resultsViewModeBinding(for: tab), @@ -706,6 +718,10 @@ struct MainEditorContentView: View { onLimitChange: onLimitChange, onOffsetChange: onOffsetChange, onPaginationGo: onPaginationGo, + onToggleColumn: { coordinator.toggleColumnVisibility($0) }, + onShowAllColumns: { coordinator.showAllColumns() }, + onHideAllColumns: { coordinator.hideAllColumns($0) }, + onToggleFilters: { coordinator.toggleFilterPanel() }, onFetchAll: { coordinator.fetchAllRows() } ) } diff --git a/TablePro/Views/Main/Child/MainStatusBarView.swift b/TablePro/Views/Main/Child/MainStatusBarView.swift index c82a8dea6..61bfb1958 100644 --- a/TablePro/Views/Main/Child/MainStatusBarView.swift +++ b/TablePro/Views/Main/Child/MainStatusBarView.swift @@ -31,8 +31,8 @@ struct StatusBarSnapshot: Equatable { struct MainStatusBarView: View { let snapshot: StatusBarSnapshot - let filterStateManager: FilterStateManager - let columnVisibilityManager: ColumnVisibilityManager + let filterState: TabFilterState + let hiddenColumns: Set let allColumns: [String] let selectedRowIndices: Set @Binding var viewMode: ResultsViewMode @@ -48,9 +48,20 @@ struct MainStatusBarView: View { let onOffsetChange: (Int) -> Void let onPaginationGo: () -> Void + // Column visibility callbacks + let onToggleColumn: (String) -> Void + let onShowAllColumns: () -> Void + let onHideAllColumns: ([String]) -> Void + + // Filter visibility callback + let onToggleFilters: () -> Void + // Truncated result callback var onFetchAll: (() -> Void)? + private var hasHiddenColumns: Bool { !hiddenColumns.isEmpty } + private var hiddenCount: Int { hiddenColumns.count } + var body: some View { HStack { if snapshot.tabId != nil { @@ -130,12 +141,12 @@ struct MainStatusBarView: View { showColumnPopover.toggle() } label: { HStack(spacing: 4) { - Image(systemName: columnVisibilityManager.hasHiddenColumns + Image(systemName: hasHiddenColumns ? "eye.slash.circle.fill" : "eye.circle") Text("Columns") - if columnVisibilityManager.hasHiddenColumns { - let visible = allColumns.count - columnVisibilityManager.hiddenCount + if hasHiddenColumns { + let visible = allColumns.count - hiddenCount Text("(\(visible)/\(allColumns.count))") .foregroundStyle(.secondary) } @@ -145,7 +156,10 @@ struct MainStatusBarView: View { .popover(isPresented: $showColumnPopover) { ColumnVisibilityPopover( columns: allColumns, - columnVisibilityManager: columnVisibilityManager + hiddenColumns: hiddenColumns, + onToggleColumn: onToggleColumn, + onShowAll: onShowAllColumns, + onHideAll: onHideAllColumns ) } } @@ -153,16 +167,16 @@ struct MainStatusBarView: View { // Filters toggle button if snapshot.tabType == .table, snapshot.hasTableName { Toggle(isOn: Binding( - get: { filterStateManager.isVisible }, - set: { _ in filterStateManager.toggle() } + get: { filterState.isVisible }, + set: { _ in onToggleFilters() } )) { HStack(spacing: 4) { - Image(systemName: filterStateManager.hasAppliedFilters + Image(systemName: filterState.hasAppliedFilters ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle") Text("Filters") - if filterStateManager.hasAppliedFilters { - Text("(\(filterStateManager.appliedFilters.count))") + if filterState.hasAppliedFilters { + Text("(\(filterState.appliedFilters.count))") .foregroundStyle(.secondary) } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnVisibility.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnVisibility.swift index 4f62e52bf..2b705b73e 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnVisibility.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnVisibility.swift @@ -6,37 +6,77 @@ import Foundation extension MainContentCoordinator { - /// Save current hidden columns to the active tab's column layout - func saveColumnVisibilityToTab() { - guard let (_, index) = tabManager.selectedTabAndIndex else { return } - tabManager.tabs[index].columnLayout.hiddenColumns = columnVisibilityManager.saveToColumnLayout() + var selectedTabHiddenColumns: Set { + guard let tab = tabManager.selectedTab else { return [] } + return tab.columnLayout.hiddenColumns } - /// Restore hidden columns from a tab's column layout - func restoreColumnVisibilityFromTab(_ tab: QueryTab) { - columnVisibilityManager.restoreFromColumnLayout(tab.columnLayout.hiddenColumns) + func hideColumn(_ columnName: String) { + mutateSelectedTabHiddenColumns { $0.insert(columnName) } + } + + func showColumn(_ columnName: String) { + mutateSelectedTabHiddenColumns { $0.remove(columnName) } + } + + func toggleColumnVisibility(_ columnName: String) { + mutateSelectedTabHiddenColumns { hidden in + if hidden.contains(columnName) { + hidden.remove(columnName) + } else { + hidden.insert(columnName) + } + } + } + + func showAllColumns() { + mutateSelectedTabHiddenColumns { $0.removeAll() } + } + + func hideAllColumns(_ columns: [String]) { + mutateSelectedTabHiddenColumns { $0 = Set(columns) } + } + + func pruneHiddenColumns(currentColumns: [String]) { + let currentSet = Set(currentColumns) + mutateSelectedTabHiddenColumns { $0 = $0.intersection(currentSet) } } - /// Load per-table hidden columns from UserDefaults when opening a table tab func restoreLastHiddenColumnsForTable(_ tableName: String) { - columnVisibilityManager.restoreLastHiddenColumns(for: tableName, connectionId: connectionId) + let restored = ColumnVisibilityPersistence.loadHiddenColumns( + for: tableName, + connectionId: connectionId + ) + mutateSelectedTabHiddenColumns { $0 = restored } } func saveColumnVisibilityForActiveTable() { - guard let tab = tabManager.selectedTab, - tab.tabType == .table, - let tableName = tab.tableContext.tableName, - !tableName.isEmpty else { return } - columnVisibilityManager.saveLastHiddenColumns(for: tableName, connectionId: connectionId) + guard let tab = tabManager.selectedTab else { return } + persistTabHiddenColumns(tab) } - /// Prune hidden columns that no longer exist in the current result set - func pruneHiddenColumns(currentColumns: [String]) { - columnVisibilityManager.pruneStaleColumns(currentColumns) + func persistOutgoingTabHiddenColumns(oldIndex: Int) { + guard tabManager.tabs.indices.contains(oldIndex) else { return } + persistTabHiddenColumns(tabManager.tabs[oldIndex]) } - /// Hide a single column (routed through coordinator for centralized control) - func hideColumn(_ columnName: String) { - columnVisibilityManager.hideColumn(columnName) + private func persistTabHiddenColumns(_ tab: QueryTab) { + guard tab.tabType == .table, + let tableName = tab.tableContext.tableName, + !tableName.isEmpty else { return } + ColumnVisibilityPersistence.saveHiddenColumns( + tab.columnLayout.hiddenColumns, + for: tableName, + connectionId: connectionId + ) + } + + private func mutateSelectedTabHiddenColumns(_ mutate: (inout Set) -> Void) { + guard let index = tabManager.selectedTabIndex else { return } + var hidden = tabManager.tabs[index].columnLayout.hiddenColumns + mutate(&hidden) + tabManager.tabs[index].columnLayout.hiddenColumns = hidden + let tabId = tabManager.tabs[index].id + tabSessionRegistry.session(for: tabId)?.columnLayout.hiddenColumns = hidden } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift index e93fb46ab..0141eb404 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift @@ -102,7 +102,7 @@ extension MainContentCoordinator { } if let tableName = tabManager.selectedTab?.tableContext.tableName { - filterStateManager.saveLastFilters(for: tableName) + saveLastFilters(for: tableName) } pendingTruncates.removeAll() @@ -118,7 +118,7 @@ extension MainContentCoordinator { private func collectInsertedRowIDs(tabId: UUID, indices: Set) -> Set { guard !indices.isEmpty else { return [] } - guard let tableRows = tableRowsStore.existingTableRows(for: tabId) else { return [] } + guard let tableRows = tabSessionRegistry.existingTableRows(for: tabId) else { return [] } var ids = Set() for index in indices where index >= 0 && index < tableRows.rows.count { let id = tableRows.rows[index].id diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift index 41fdc3b26..56c62abb2 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift @@ -46,9 +46,6 @@ extension MainContentCoordinator { current.tableContext.databaseName == currentDatabase, current.tableContext.schemaName == targetSchema { applyFKFilter(filter, for: referencedTable) - if let (_, tabIndex) = tabManager.selectedTabAndIndex { - tabManager.tabs[tabIndex].filterState = filterStateManager.saveToTabState() - } return } @@ -100,7 +97,7 @@ extension MainContentCoordinator { NSApp.keyWindow?.title = referencedTable guard let (tab, tabIndex) = tabManager.selectedTabAndIndex else { return } - let tableRows = tableRowsStore.tableRows(for: tab.id) + let tableRows = tabSessionRegistry.tableRows(for: tab.id) let filteredQuery = queryBuilder.buildFilteredQuery( tableName: referencedTable, schemaName: fkInfo.referencedSchema, @@ -113,15 +110,9 @@ extension MainContentCoordinator { updateFilterState(filter, for: referencedTable) - // Persist FK filter to new tab so .onChange → handleTabChange restores it correctly - tabManager.tabs[tabIndex].filterState = filterStateManager.saveToTabState() - runQuery() } else { applyFKFilter(filter, for: referencedTable) - if let (_, tabIndex) = tabManager.selectedTabAndIndex { - tabManager.tabs[tabIndex].filterState = filterStateManager.saveToTabState() - } } } @@ -147,6 +138,6 @@ extension MainContentCoordinator { } private func updateFilterState(_ filter: TableFilter, for tableName: String) { - filterStateManager.setFKFilter(filter) + setFKFilter(filter) } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+FilterState.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+FilterState.swift new file mode 100644 index 000000000..5278f1ce4 --- /dev/null +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+FilterState.swift @@ -0,0 +1,314 @@ +// +// MainContentCoordinator+FilterState.swift +// TablePro +// + +import Foundation +import os +import SwiftUI + +private let filterStateLog = Logger(subsystem: "com.TablePro", category: "FilterState") + +extension MainContentCoordinator { + var selectedTabFilterState: TabFilterState { + tabManager.selectedTab?.filterState ?? TabFilterState() + } + + // MARK: - Filter Management + + func addFilter(columns: [String] = [], primaryKeyColumn: String? = nil) { + let settings = FilterSettingsStorage.shared.loadSettings() + var newFilter = TableFilter() + + switch settings.defaultColumn { + case .rawSQL: + newFilter.columnName = TableFilter.rawSQLColumn + case .primaryKey: + if let pk = primaryKeyColumn { + newFilter.columnName = pk + } else if let firstColumn = columns.first { + newFilter.columnName = firstColumn + } + case .anyColumn: + if let firstColumn = columns.first { + newFilter.columnName = firstColumn + } + } + + newFilter.filterOperator = settings.defaultOperator.toFilterOperator() + newFilter.isSelected = true + + mutateSelectedTabFilterState { state in + state.filters.append(newFilter) + } + } + + func addFilterForColumn(_ columnName: String) { + let settings = FilterSettingsStorage.shared.loadSettings() + var newFilter = TableFilter() + newFilter.columnName = columnName + newFilter.filterOperator = settings.defaultOperator.toFilterOperator() + newFilter.isSelected = true + + mutateSelectedTabFilterState { state in + state.filters.append(newFilter) + if !state.isVisible { + state.isVisible = true + } + } + } + + func setFKFilter(_ filter: TableFilter) { + mutateSelectedTabFilterState { state in + state.filters = [filter] + state.appliedFilters = [filter] + state.isVisible = true + state.filterLogicMode = .and + } + } + + func duplicateFilter(_ filter: TableFilter) { + let copy = TableFilter( + id: UUID(), + columnName: filter.columnName, + filterOperator: filter.filterOperator, + value: filter.value, + secondValue: filter.secondValue, + isSelected: true, + isEnabled: filter.isEnabled, + rawSQL: filter.rawSQL + ) + mutateSelectedTabFilterState { state in + if let index = state.filters.firstIndex(where: { $0.id == filter.id }) { + state.filters.insert(copy, at: index + 1) + } else { + state.filters.append(copy) + } + } + } + + func removeFilter(_ filter: TableFilter) { + mutateSelectedTabFilterState { state in + state.filters.removeAll { $0.id == filter.id } + state.appliedFilters.removeAll { $0.id == filter.id } + } + } + + func updateFilter(_ filter: TableFilter) { + mutateSelectedTabFilterState { state in + if let index = state.filters.firstIndex(where: { $0.id == filter.id }) { + state.filters[index] = filter + } + } + } + + func filterBinding(for filter: TableFilter) -> Binding { + Binding( + get: { [weak self] in + self?.selectedTabFilterState.filters.first { $0.id == filter.id } ?? filter + }, + set: { [weak self] newValue in + self?.updateFilter(newValue) + } + ) + } + + func filterLogicModeBinding() -> Binding { + Binding( + get: { [weak self] in + self?.selectedTabFilterState.filterLogicMode ?? .and + }, + set: { [weak self] newValue in + self?.mutateSelectedTabFilterState { $0.filterLogicMode = newValue } + } + ) + } + + // MARK: - Apply + + func applySingleFilter(_ filter: TableFilter) { + guard filter.isValid else { return } + mutateSelectedTabFilterState { state in + state.filters = [filter] + state.appliedFilters = [filter] + state.isVisible = true + } + } + + func applySelectedFilters() { + mutateSelectedTabFilterState { state in + state.appliedFilters = state.filters.filter { $0.isSelected && $0.isValid } + } + } + + func applyAllFilters() { + mutateSelectedTabFilterState { state in + state.appliedFilters = state.filters.filter { $0.isEnabled && $0.isValid } + } + } + + func clearAppliedFilters() { + mutateSelectedTabFilterState { state in + state.appliedFilters = [] + } + } + + // MARK: - Panel Visibility + + func toggleFilterPanel() { + withAnimation(.easeInOut(duration: 0.15)) { + mutateSelectedTabFilterState { state in + state.isVisible.toggle() + } + } + } + + func showFilterPanel() { + withAnimation(.easeInOut(duration: 0.15)) { + mutateSelectedTabFilterState { state in + state.isVisible = true + } + } + } + + func closeFilterPanel() { + withAnimation(.easeInOut(duration: 0.15)) { + mutateSelectedTabFilterState { state in + state.isVisible = false + } + } + } + + // MARK: - Selection + + func selectAllFilters(_ selected: Bool) { + mutateSelectedTabFilterState { state in + for index in 0.. [FilterPreset] { + FilterPresetStorage.shared.loadAllPresets() + } + + func deleteFilterPreset(_ preset: FilterPreset) { + FilterPresetStorage.shared.deletePreset(preset) + } + + // MARK: - SQL Preview + + func generateFilterPreviewSQL(databaseType: DatabaseType) -> String { + let state = selectedTabFilterState + guard let dialect = PluginManager.shared.sqlDialect(for: databaseType) else { + return "-- Filters are applied natively" + } + let generator = FilterSQLGenerator(dialect: dialect) + let filtersToPreview = filtersForPreview(in: state) + + if filtersToPreview.isEmpty && !state.filters.isEmpty { + let invalidCount = state.filters.count(where: { !$0.isValid }) + if invalidCount > 0 { + return "-- No valid filters to preview\n-- Complete \(invalidCount) filter(s) by:\n-- • Selecting a column\n-- • Entering a value (if required)\n-- • Filling in second value for BETWEEN" + } + } + + return generator.generateWhereClause(from: filtersToPreview, logicMode: state.filterLogicMode) + } + + private func filtersForPreview(in state: TabFilterState) -> [TableFilter] { + var valid: [TableFilter] = [] + var selectedValid: [TableFilter] = [] + for filter in state.filters where filter.isEnabled && filter.isValid { + valid.append(filter) + if filter.isSelected { selectedValid.append(filter) } + } + if selectedValid.count == valid.count || selectedValid.isEmpty { + return valid + } + return selectedValid + } + + // MARK: - Private + + private func mutateSelectedTabFilterState(_ mutate: (inout TabFilterState) -> Void) { + guard let index = tabManager.selectedTabIndex else { return } + var state = tabManager.tabs[index].filterState + mutate(&state) + tabManager.tabs[index].filterState = state + let tabId = tabManager.tabs[index].id + if let session = tabSessionRegistry.session(for: tabId) { + session.filterState = state + } else { + filterStateLog.error( + "TabSession missing for selected tab \(tabId, privacy: .public); QueryTab updated but session mirror skipped" + ) + assertionFailure("TabSession missing for selected tab — registry sync regression") + } + } +} diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Filtering.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Filtering.swift index 324eab239..29d7c40ca 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Filtering.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Filtering.swift @@ -25,12 +25,12 @@ extension MainContentCoordinator { self.tabManager.tabs[capturedTabIndex].pagination.reset() let tab = self.tabManager.tabs[capturedTabIndex] - let buffer = self.tableRowsStore.tableRows(for: tab.id) + let buffer = self.tabSessionRegistry.tableRows(for: tab.id) let exclusions = self.columnExclusions(for: capturedTableName) let newQuery = self.queryBuilder.buildFilteredQuery( tableName: capturedTableName, filters: capturedFilters, - logicMode: self.filterStateManager.filterLogicMode, + logicMode: tab.filterState.filterLogicMode, sortState: tab.sortState, columns: buffer.columns, limit: tab.pagination.pageSize, @@ -41,12 +41,9 @@ extension MainContentCoordinator { self.tabManager.tabs[capturedTabIndex].content.query = newQuery if !capturedFilters.isEmpty { - self.filterStateManager.saveLastFilters(for: capturedTableName) + self.saveLastFilters(for: capturedTableName) } - // Persist filter state to tab so it survives tab switches - self.tabManager.tabs[capturedTabIndex].filterState = self.filterStateManager.saveToTabState() - self.runQuery() } } @@ -62,7 +59,7 @@ extension MainContentCoordinator { guard capturedTabIndex < self.tabManager.tabs.count else { return } let tab = self.tabManager.tabs[capturedTabIndex] - let buffer = self.tableRowsStore.tableRows(for: tab.id) + let buffer = self.tabSessionRegistry.tableRows(for: tab.id) let exclusions = self.columnExclusions(for: capturedTableName) let newQuery = self.queryBuilder.buildBaseQuery( tableName: capturedTableName, @@ -74,16 +71,14 @@ extension MainContentCoordinator { ) self.tabManager.tabs[capturedTabIndex].content.query = newQuery - self.tabManager.tabs[capturedTabIndex].filterState = self.filterStateManager.saveToTabState() self.runQuery() } } func restoreFiltersForTable(_ tableName: String) { - filterStateManager.restoreLastFilters(for: tableName) + restoreLastFilters(for: tableName) guard let (_, tabIndex) = tabManager.selectedTabAndIndex else { return } - tabManager.tabs[tabIndex].filterState = filterStateManager.saveToTabState() - if filterStateManager.hasAppliedFilters { + if tabManager.tabs[tabIndex].filterState.hasAppliedFilters { rebuildTableQuery(at: tabIndex) } } @@ -93,16 +88,16 @@ extension MainContentCoordinator { let tableName = tabManager.tabs[tabIndex].tableContext.tableName else { return } let tab = tabManager.tabs[tabIndex] - let buffer = tableRowsStore.tableRows(for: tab.id) - let hasFilters = filterStateManager.hasAppliedFilters + let buffer = tabSessionRegistry.tableRows(for: tab.id) + let hasFilters = tab.filterState.hasAppliedFilters let exclusions = columnExclusions(for: tableName) let newQuery: String if hasFilters { newQuery = queryBuilder.buildFilteredQuery( tableName: tableName, - filters: filterStateManager.appliedFilters, - logicMode: filterStateManager.filterLogicMode, + filters: tab.filterState.appliedFilters, + logicMode: tab.filterState.filterLogicMode, sortState: tab.sortState, columns: buffer.columns, limit: tab.pagination.pageSize, diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift index f95246e80..b0d8f1212 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift @@ -41,7 +41,7 @@ extension MainContentCoordinator { tab.pagination.hasMoreRows, let baseQuery = tab.pagination.baseQueryForMore else { return } - let loadedCount = tableRowsStore.tableRows(for: tab.id).rows.count + let loadedCount = tabSessionRegistry.tableRows(for: tab.id).rows.count let totalEstimate = tab.pagination.totalRowCount let message: String diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+MongoDB.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+MongoDB.swift deleted file mode 100644 index 63bb504df..000000000 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+MongoDB.swift +++ /dev/null @@ -1,11 +0,0 @@ -// -// MainContentCoordinator+MongoDB.swift -// TablePro -// -// MongoDB-specific query helpers for MainContentCoordinator. -// - -import Foundation - -extension MainContentCoordinator { -} diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index d085311ae..ad69f4a62 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -127,7 +127,7 @@ extension MainContentCoordinator { // opening new native window tabs (e.g. Redis database switching). if navigationModel == .inPlace { if let oldTab = tabManager.selectedTab, let oldTableName = oldTab.tableContext.tableName { - filterStateManager.saveLastFilters(for: oldTableName) + saveLastFilters(for: oldTableName) } do { let replaced = try tabManager.replaceTabContent( @@ -137,7 +137,7 @@ extension MainContentCoordinator { schemaName: currentSchema ) if replaced { - filterStateManager.clearAll() + clearFilterState() if let (tab, tabIndex) = tabManager.selectedTabAndIndex { setActiveTableRows(TableRows(), for: tab.id) tabManager.tabs[tabIndex].pagination.reset() @@ -157,7 +157,7 @@ extension MainContentCoordinator { // If current tab has unsaved changes, active filters, or sorting, open in a new native tab let hasActiveWork = changeManager.hasChanges - || filterStateManager.hasAppliedFilters + || selectedTabFilterState.hasAppliedFilters || (tabManager.selectedTab?.sortState.isSorting ?? false) if hasActiveWork { let payload = EditorTabPayload( @@ -211,7 +211,7 @@ extension MainContentCoordinator { } if let oldTab = previewCoordinator.tabManager.selectedTab, let oldTableName = oldTab.tableContext.tableName { - previewCoordinator.filterStateManager.saveLastFilters(for: oldTableName) + previewCoordinator.saveLastFilters(for: oldTableName) } do { try previewCoordinator.tabManager.replaceTabContent( @@ -226,7 +226,7 @@ extension MainContentCoordinator { navigationLogger.error("openPreviewTab replaceTabContent failed: \(error.localizedDescription, privacy: .public)") return } - previewCoordinator.filterStateManager.clearAll() + previewCoordinator.clearFilterState() if let tabIndex = previewCoordinator.tabManager.selectedTabIndex { let tabId = previewCoordinator.tabManager.tabs[tabIndex].id previewCoordinator.setActiveTableRows(TableRows(), for: tabId) @@ -250,7 +250,7 @@ extension MainContentCoordinator { if tab.isPreview { return true } // Table tab with no active work if tab.tabType == .table && !changeManager.hasChanges - && !filterStateManager.hasAppliedFilters && !tab.sortState.isSorting { + && !selectedTabFilterState.hasAppliedFilters && !tab.sortState.isSorting { return true } // Empty/default query tab (no user content, no results, never executed) @@ -270,7 +270,7 @@ extension MainContentCoordinator { tab.tabType == .query && !tab.content.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } ?? false let previewHasWork = changeManager.hasChanges - || filterStateManager.hasAppliedFilters + || selectedTabFilterState.hasAppliedFilters || selectedTab.sortState.isSorting || hasUnsavedQuery if previewHasWork { @@ -288,7 +288,7 @@ extension MainContentCoordinator { return } if let oldTableName = selectedTab.tableContext.tableName { - filterStateManager.saveLastFilters(for: oldTableName) + saveLastFilters(for: oldTableName) } do { try tabManager.replaceTabContent( @@ -303,7 +303,7 @@ extension MainContentCoordinator { navigationLogger.error("openPreviewTab replaceTabContent failed: \(error.localizedDescription, privacy: .public)") return } - filterStateManager.clearAll() + clearFilterState() if let (tab, tabIndex) = tabManager.selectedTabAndIndex { setActiveTableRows(TableRows(), for: tab.id) tabManager.tabs[tabIndex].display.resultsViewMode = showStructure ? .structure : .data @@ -405,7 +405,7 @@ extension MainContentCoordinator { /// Switch to a different database (called from database switcher) func switchDatabase(to database: String) async { - filterStateManager.clearAll() + clearFilterState() let previousDatabase = toolbarState.databaseName toolbarState.databaseName = database @@ -414,7 +414,7 @@ extension MainContentCoordinator { closeSiblingNativeWindows() persistence.saveNowSync(tabs: tabManager.tabs, selectedTabId: tabManager.selectedTabId) - tableRowsStore.tearDown() + tabSessionRegistry.removeAll() tabManager.tabs = [] tabManager.selectedTabId = nil await SchemaService.shared.invalidate(connectionId: connectionId) @@ -436,7 +436,7 @@ extension MainContentCoordinator { func switchSchema(to schema: String) async { guard PluginManager.shared.supportsSchemaSwitching(for: connection.type) else { return } - filterStateManager.clearAll() + clearFilterState() let previousSchema = toolbarState.databaseName toolbarState.databaseName = schema @@ -445,7 +445,7 @@ extension MainContentCoordinator { closeSiblingNativeWindows() persistence.saveNowSync(tabs: tabManager.tabs, selectedTabId: tabManager.selectedTabId) - tableRowsStore.tearDown() + tabSessionRegistry.removeAll() tabManager.tabs = [] tabManager.selectedTabId = nil await SchemaService.shared.invalidate(connectionId: connectionId) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift index 5a3d82f61..74fb19693 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift @@ -2,141 +2,38 @@ // MainContentCoordinator+QueryHelpers.swift // TablePro // -// Query execution helper methods: schema parsing, metadata caching, -// phase result application, and error handling. -// import AppKit import Foundation import os import TableProPluginKit -private let progressLog = Logger(subsystem: "com.TablePro", category: "ProgressiveLoad") - -/// Result of the data fetch phase -internal struct QueryFetchResult { - let columns: [String] - let columnTypes: [ColumnType] - let rows: [[String?]] - let executionTime: TimeInterval - let rowsAffected: Int - let statusMessage: String? - let isTruncated: Bool -} - -// MARK: - Query Execution Helpers - extension MainContentCoordinator { - /// Execute a user-supplied SQL query, applying an optional row cap on the result set - nonisolated static func fetchQueryData( - driver: DatabaseDriver, - sql: String, - rowCap: Int? - ) async throws -> QueryFetchResult { - let start = CFAbsoluteTimeGetCurrent() - progressLog.info("[executeUserQuery] sql=\(sql.prefix(100), privacy: .public) rowCap=\(rowCap?.description ?? "nil")") - let result = try await driver.executeUserQuery(query: sql, rowCap: rowCap, parameters: nil) - let elapsed = CFAbsoluteTimeGetCurrent() - start - progressLog.info("[executeUserQuery] rows=\(result.rows.count) truncated=\(result.isTruncated) driverTime=\(String(format: "%.3f", result.executionTime))s totalTime=\(String(format: "%.3f", elapsed))s") - return QueryFetchResult( - columns: result.columns, - columnTypes: result.columnTypes, - rows: result.rows, - executionTime: result.executionTime, - rowsAffected: result.rowsAffected, - statusMessage: result.statusMessage, - isTruncated: result.isTruncated - ) - } - - nonisolated static func fetchQueryDataParameterized( - driver: DatabaseDriver, - sql: String, - parameters: [Any?], - rowCap: Int? - ) async throws -> QueryFetchResult { - let start = CFAbsoluteTimeGetCurrent() - progressLog.info("[executeUserQueryParameterized] sql=\(sql.prefix(100), privacy: .public) rowCap=\(rowCap?.description ?? "nil") params=\(parameters.count)") - let result = try await driver.executeUserQuery(query: sql, rowCap: rowCap, parameters: parameters) - let elapsed = CFAbsoluteTimeGetCurrent() - start - progressLog.info("[executeUserQueryParameterized] rows=\(result.rows.count) truncated=\(result.isTruncated) driverTime=\(String(format: "%.3f", result.executionTime))s totalTime=\(String(format: "%.3f", elapsed))s") - return QueryFetchResult( - columns: result.columns, - columnTypes: result.columnTypes, - rows: result.rows, - executionTime: result.executionTime, - rowsAffected: result.rowsAffected, - statusMessage: result.statusMessage, - isTruncated: result.isTruncated - ) - } - - /// Decide whether to apply the configured row cap to a user query. - /// Returns the cap value if truncation is enabled and the query is a non-write SELECT/WITH on a query tab. func resolveRowCap(sql: String, tabType: TabType) -> Int? { - let dataGridSettings = AppSettingsManager.shared.dataGrid - let trimmedUpper = sql.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() - let isSelectQuery = trimmedUpper.hasPrefix("SELECT ") || trimmedUpper.hasPrefix("WITH ") - - guard tabType == .query, isSelectQuery, !isWriteQuery(sql), !isDDLQuery(sql), - dataGridSettings.truncateQueryResults - else { - return nil - } - return dataGridSettings.validatedQueryResultRowCap + QueryExecutor.resolveRowCap(sql: sql, tabType: tabType, databaseType: connection.type) } - /// Parsed schema metadata ready to apply to a tab - struct ParsedSchemaMetadata { - let columnDefaults: [String: String?] - let columnForeignKeys: [String: ForeignKeyInfo] - let columnNullable: [String: Bool] - let primaryKeyColumns: [String] - let approximateRowCount: Int? - let columnEnumValues: [String: [String]] + func parseSchemaMetadata(_ schema: SchemaResult) -> ParsedSchemaMetadata { + QueryExecutor.parseSchemaMetadata(schema) } - /// Schema result from parallel or sequential metadata fetch - typealias SchemaResult = (columnInfo: [ColumnInfo], fkInfo: [ForeignKeyInfo], approximateRowCount: Int?) - - /// Parse a SchemaResult into dictionaries ready for tab assignment - func parseSchemaMetadata(_ schema: SchemaResult) -> ParsedSchemaMetadata { - var defaults: [String: String?] = [:] - var fks: [String: ForeignKeyInfo] = [:] - var nullable: [String: Bool] = [:] - for col in schema.columnInfo { - defaults[col.name] = col.defaultValue - nullable[col.name] = col.isNullable - } - for fk in schema.fkInfo { - fks[fk.column] = fk - } - // Parse enum/set values from column type definitions (MySQL, MariaDB, ClickHouse) - var enumValues: [String: [String]] = [:] - for col in schema.columnInfo { - if let values = ColumnType.parseEnumValues(from: col.dataType) { - enumValues[col.name] = values - } else if let values = ColumnType.parseClickHouseEnumValues(from: col.dataType) { - enumValues[col.name] = values - } - } - return ParsedSchemaMetadata( - columnDefaults: defaults, - columnForeignKeys: fks, - columnNullable: nullable, - primaryKeyColumns: schema.columnInfo.filter { $0.isPrimaryKey }.map(\.name), - approximateRowCount: schema.approximateRowCount, - columnEnumValues: enumValues + func awaitSchemaResult( + parallelTask: Task?, + tableName: String + ) async -> SchemaResult? { + await QueryExecutor.awaitSchemaResult( + connectionId: connectionId, + parallelTask: parallelTask, + tableName: tableName ) } - /// Check whether metadata is already cached for the given table in a tab func isMetadataCached(tabId: UUID, tableName: String) -> Bool { guard let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return false } let tab = tabManager.tabs[idx] - let tableRows = tableRowsStore.tableRows(for: tab.id) + let tableRows = tabSessionRegistry.tableRows(for: tab.id) guard tab.tableContext.tableName == tableName, !tableRows.columnDefaults.isEmpty, !tab.tableContext.primaryKeyColumns.isEmpty else { @@ -154,28 +51,6 @@ extension MainContentCoordinator { return true } - /// Await schema metadata from parallel task or fall back to sequential fetch - func awaitSchemaResult( - parallelTask: Task?, - tableName: String - ) async -> SchemaResult? { - if let parallelTask { - return try? await parallelTask.value - } - guard let driver = DatabaseManager.shared.driver(for: connectionId) else { return nil } - do { - async let cols = driver.fetchColumns(table: tableName) - async let fks = driver.fetchForeignKeys(table: tableName) - let (c, f) = try await (cols, fks) - let approxCount = try? await driver.fetchApproximateRowCount(table: tableName) - return (columnInfo: c, fkInfo: f, approximateRowCount: approxCount) - } catch { - Self.logger.error("Phase 2 schema fetch failed: \(error.localizedDescription)") - return nil - } - } - - /// Apply Phase 1 query result data and optional metadata to the tab func applyPhase1Result( // swiftlint:disable:this function_parameter_count tabId: UUID, columns: [String], @@ -226,7 +101,7 @@ extension MainContentCoordinator { updatedTab.pagination.isApproximateRowCount = true } } else { - let existing = tableRowsStore.tableRows(for: updatedTab.id) + let existing = tabSessionRegistry.tableRows(for: updatedTab.id) columnDefaults = existing.columnDefaults columnForeignKeys = existing.columnForeignKeys columnNullable = existing.columnNullable @@ -427,7 +302,7 @@ extension MainContentCoordinator { guard capturedGeneration == queryGeneration else { return } guard !Task.isCancelled else { return } if let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) { - let existing = tableRowsStore.tableRows(for: tabId) + let existing = tabSessionRegistry.tableRows(for: tabId) let hasNewValues = columnEnumValues.contains { key, value in existing.columnEnumValues[key] != value } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryParameters.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryParameters.swift index ebee8cc83..d15372d8b 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryParameters.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryParameters.swift @@ -6,20 +6,7 @@ private let paramLog = Logger(subsystem: "com.TablePro", category: "QueryParamet extension MainContentCoordinator { func detectAndReconcileParameters(sql: String, existing: [QueryParameter]) -> [QueryParameter] { - let detectedNames = SQLParameterExtractor.extractParameters(from: sql) - guard !detectedNames.isEmpty else { return [] } - - let existingByName = Dictionary( - existing.map { ($0.name, $0) }, - uniquingKeysWith: { first, _ in first } - ) - - return detectedNames.map { name in - if let existing = existingByName[name] { - return existing - } - return QueryParameter(name: name) - } + QueryExecutor.detectAndReconcileParameters(sql: sql, existing: existing) } func executeQueryWithParameters(_ sql: String, parameters: [QueryParameter]) { @@ -91,76 +78,39 @@ extension MainContentCoordinator { let tabId = tabManager.tabs[index].id let rowCap = resolveRowCap(sql: sql, tabType: tab.tabType) - let effectiveSQL = sql + let (tableName, isEditable) = resolveTableEditability(tab: tab, sql: sql) - let (tableName, isEditable) = resolveTableEditability(tab: tab, sql: effectiveSQL) + let needsMetadataFetch: Bool + if isEditable, let tableName { + needsMetadataFetch = !isMetadataCached(tabId: tabId, tableName: tableName) + } else { + needsMetadataFetch = false + } currentQueryTask = Task { [weak self] in guard let self else { return } do { - var parallelSchemaTask: Task? - var needsMetadataFetch = false - - if isEditable, let tableName = tableName { - let cached = isMetadataCached(tabId: tabId, tableName: tableName) - needsMetadataFetch = !cached - - if needsMetadataFetch { - let connId = connectionId - parallelSchemaTask = Task { - guard let driver = DatabaseManager.shared.driver(for: connId) else { - throw DatabaseError.notConnected - } - async let cols = driver.fetchColumns(table: tableName) - async let fks = driver.fetchForeignKeys(table: tableName) - let result = try await (columnInfo: cols, fkInfo: fks) - let approxCount = try? await driver.fetchApproximateRowCount( - table: tableName - ) - return ( - columnInfo: result.columnInfo, - fkInfo: result.fkInfo, - approximateRowCount: approxCount - ) - } - } - } - - guard let queryDriver = DatabaseManager.shared.driver(for: connectionId) else { - throw DatabaseError.notConnected - } - let fetchResult: QueryFetchResult - do { - fetchResult = try await Self.fetchQueryDataParameterized( - driver: queryDriver, - sql: effectiveSQL, - parameters: parameters, - rowCap: rowCap - ) - } - let safeExecutionTime = fetchResult.executionTime + let executionResult = try await queryExecutor.executeQuery( + sql: sql, + parameters: parameters, + rowCap: rowCap, + tableName: tableName, + fetchSchemaForTable: needsMetadataFetch + ) guard !Task.isCancelled else { - parallelSchemaTask?.cancel() - await resetExecutionState(tabId: tabId, executionTime: safeExecutionTime) - return - } - - var schemaResult: SchemaResult? - if needsMetadataFetch { - schemaResult = await awaitSchemaResult( - parallelTask: parallelSchemaTask, - tableName: tableName ?? "" + await resetExecutionState( + tabId: tabId, + executionTime: executionResult.fetchResult.executionTime ) + return } - let metadata = schemaResult.map { self.parseSchemaMetadata($0) } - await applyParameterizedResult( tabId: tabId, - fetchResult: fetchResult, - schemaResult: schemaResult, + fetchResult: executionResult.fetchResult, + schemaResult: executionResult.schemaResult, tableName: tableName, isEditable: isEditable, sql: sql, @@ -170,14 +120,14 @@ extension MainContentCoordinator { nativeParameters: parameters ) - if isEditable, let tableName = tableName { + if isEditable, let tableName { if needsMetadataFetch { launchPhase2Work( tableName: tableName, tabId: tabId, capturedGeneration: capturedGeneration, connectionType: conn.type, - schemaResult: schemaResult + schemaResult: executionResult.schemaResult ) } else { launchPhase2Count( @@ -392,7 +342,7 @@ extension MainContentCoordinator { originalParameters: [QueryParameter], nativeParameters: [Any?] ) async { - let metadata = schemaResult.map { self.parseSchemaMetadata($0) } + let metadata = schemaResult.map { QueryExecutor.parseSchemaMetadata($0) } await MainActor.run { [weak self] in guard let self else { return } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift index 576a077da..a14114951 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift @@ -8,8 +8,8 @@ extension MainContentCoordinator { tab.tableContext.tableName != nil else { return } let tabId = tab.id - let columnDefaults = tableRowsStore.tableRows(for: tabId).columnDefaults - let columns = tableRowsStore.tableRows(for: tabId).columns + let columnDefaults = tabSessionRegistry.tableRows(for: tabId).columnDefaults + let columns = tabSessionRegistry.tableRows(for: tabId).columns dataTabDelegate?.tableViewCoordinator?.commitActiveCellEdit() @@ -55,7 +55,7 @@ extension MainContentCoordinator { return result.delta } - let totalRows = tableRowsStore.tableRows(for: tabId).count + let totalRows = tabSessionRegistry.tableRows(for: tabId).count if deleteResult.nextRowToSelect >= 0 && deleteResult.nextRowToSelect < totalRows { selectionState.indices = [deleteResult.nextRowToSelect] } else { @@ -79,8 +79,8 @@ extension MainContentCoordinator { tab.tableContext.tableName != nil else { return } let tabId = tab.id - let columns = tableRowsStore.tableRows(for: tabId).columns - guard index >= 0, index < tableRowsStore.tableRows(for: tabId).count else { return } + let columns = tabSessionRegistry.tableRows(for: tabId).columns + guard index >= 0, index < tabSessionRegistry.tableRows(for: tabId).count else { return } dataTabDelegate?.tableViewCoordinator?.commitActiveCellEdit() @@ -151,7 +151,7 @@ extension MainContentCoordinator { func copySelectedRowsToClipboard(indices: Set) { guard let (tab, _) = tabManager.selectedTabAndIndex, !indices.isEmpty else { return } - let tableRows = tableRowsStore.tableRows(for: tab.id) + let tableRows = tabSessionRegistry.tableRows(for: tab.id) rowOperationsManager.copySelectedRowsToClipboard( selectedIndices: indices, tableRows: tableRows @@ -160,7 +160,7 @@ extension MainContentCoordinator { func copySelectedRowsWithHeaders(indices: Set) { guard let (tab, _) = tabManager.selectedTabAndIndex, !indices.isEmpty else { return } - let tableRows = tableRowsStore.tableRows(for: tab.id) + let tableRows = tabSessionRegistry.tableRows(for: tab.id) rowOperationsManager.copySelectedRowsToClipboard( selectedIndices: indices, tableRows: tableRows, @@ -170,7 +170,7 @@ extension MainContentCoordinator { func copySelectedRowsAsJson(indices: Set) { guard let (tab, _) = tabManager.selectedTabAndIndex, !indices.isEmpty else { return } - let tableRows = tableRowsStore.tableRows(for: tab.id) + let tableRows = tabSessionRegistry.tableRows(for: tab.id) let rows = indices.sorted().compactMap { idx -> [String?]? in guard idx >= 0, idx < tableRows.count else { return nil } return tableRows.rows[idx].values @@ -189,7 +189,7 @@ extension MainContentCoordinator { tab.tabType == .table else { return } let tabId = tab.id - let columns = tableRowsStore.tableRows(for: tabId).columns + let columns = tabSessionRegistry.tableRows(for: tabId).columns var pasteResult = RowOperationsManager.PasteRowsResult(pastedRows: [], delta: .none) mutateActiveTableRows(for: tabId) { rows in diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift index af68e7f37..5f5de8981 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift @@ -249,7 +249,7 @@ extension MainContentCoordinator { let firstRemovedIndex = tabManager.tabs .firstIndex { tabIdsToRemove.contains($0.id) } ?? 0 for tabId in tabIdsToRemove { - tableRowsStore.removeTableRows(for: tabId) + tabSessionRegistry.removeTableRows(for: tabId) } tabManager.tabs.removeAll { tabIdsToRemove.contains($0.id) } if !tabManager.tabs.isEmpty { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift index 21b9feea8..96eb56084 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift @@ -107,7 +107,7 @@ extension MainContentCoordinator { func openExportQueryResultsDialog() { guard let tab = tabManager.selectedTab, - !tableRowsStore.tableRows(for: tab.id).rows.isEmpty else { return } + !tabSessionRegistry.tableRows(for: tab.id).rows.isEmpty else { return } activeSheet = .exportQueryResults } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift index 998705365..ec136c88b 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift @@ -23,7 +23,7 @@ extension MainContentCoordinator { let editedFields = editState.getEditedFields() guard !editedFields.isEmpty else { return } - let tableRows = tableRowsStore.tableRows(for: tab.id) + let tableRows = tabSessionRegistry.tableRows(for: tab.id) let changes: [RowChange] = selectionState.indices.sorted().compactMap { rowIndex in guard rowIndex < tableRows.rows.count else { return nil } let originalRow = tableRows.rows[rowIndex].values diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift index 3ca061d3f..1880fa2b9 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift @@ -27,7 +27,6 @@ extension MainContentCoordinator { ) } - // Phase: save outgoing tab state let saveStart = Date() if let oldId = oldTabId, let oldIndex = tabManager.tabs.firstIndex(where: { $0.id == oldId }) @@ -35,35 +34,31 @@ extension MainContentCoordinator { if changeManager.hasChanges { tabManager.tabs[oldIndex].pendingChanges = changeManager.saveState() } - tabManager.tabs[oldIndex].filterState = filterStateManager.saveToTabState() if let tableName = tabManager.tabs[oldIndex].tableContext.tableName { - filterStateManager.saveLastFilters(for: tableName) + FilterSettingsStorage.shared.saveLastFilters( + tabManager.tabs[oldIndex].filterState.appliedFilters, + for: tableName + ) } - saveColumnVisibilityToTab() - saveColumnVisibilityForActiveTable() + persistOutgoingTabHiddenColumns(oldIndex: oldIndex) } let saveMs = Int(Date().timeIntervalSince(saveStart) * 1_000) - // Phase: evict inactive tabs - let evictStart = Date() + // Defer to the next run-loop tick so the synchronous switch path + // stays cheap; the sort + budget calculation is non-trivial on + // connections with many open tabs. if tabManager.tabs.count > 2 { let activeIds: Set = Set([oldTabId, newTabId].compactMap { $0 }) - evictInactiveTabs(excluding: activeIds) + Task { @MainActor [weak self] in + self?.evictInactiveTabs(excluding: activeIds) + } } - let evictMs = Int(Date().timeIntervalSince(evictStart) * 1_000) - // Phase: restore incoming tab state let restoreStart = Date() if let newId = newTabId, let newIndex = tabManager.tabs.firstIndex(where: { $0.id == newId }) { let newTab = tabManager.tabs[newIndex] - let newRows = tableRowsStore.tableRows(for: newId) - - // Restore filter state for new tab - filterStateManager.restoreFromTabState(newTab.filterState) - - // Restore column visibility for new tab - columnVisibilityManager.restoreFromColumnLayout(newTab.columnLayout.hiddenColumns) + let newRows = tabSessionRegistry.tableRows(for: newId) selectionState.indices = newTab.selectedRowIndices toolbarState.isTableTab = newTab.tabType == .table @@ -86,7 +81,7 @@ extension MainContentCoordinator { let restoreMs = Int(Date().timeIntervalSince(restoreStart) * 1_000) Self.lifecycleLogger.debug( - "[switch] handleTabChange phases: saveOutgoing=\(saveMs)ms evict=\(evictMs)ms restoreIncoming=\(restoreMs)ms" + "[switch] handleTabChange phases: saveOutgoing=\(saveMs)ms restoreIncoming=\(restoreMs)ms" ) if !newTab.tableContext.databaseName.isEmpty { @@ -109,46 +104,10 @@ extension MainContentCoordinator { } } - // If the tab shows isExecuting but has no results, the previous query was - // likely cancelled when the user rapidly switched away. Force-clear the stale - // flag so the lazy-load check below can re-execute the query. - if newTab.execution.isExecuting && newRows.rows.isEmpty && newTab.execution.lastExecutedAt == nil { - let tabId = newId - Task { [weak self] in - guard let self, - let idx = self.tabManager.tabs.firstIndex(where: { $0.id == tabId }), - self.tabManager.tabs[idx].execution.isExecuting else { return } - self.tabManager.tabs[idx].execution.isExecuting = false - } - } - - let isEvicted = tableRowsStore.isEvicted(newId) - let needsLazyQuery = newTab.tabType == .table - && (newRows.rows.isEmpty || isEvicted) - && (newTab.execution.lastExecutedAt == nil || isEvicted) - && newTab.execution.errorMessage == nil - && !newTab.content.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - - if needsLazyQuery { - if let session = DatabaseManager.shared.session(for: connectionId), session.isConnected { - Self.lifecycleLogger.debug( - "[switch] handleTabChange lazy query executing (eviction=\(isEvicted)) tabId=\(newId, privacy: .public)" - ) - executeTableTabQueryDirectly() - } else { - Self.lifecycleLogger.debug( - "[switch] handleTabChange lazy query deferred (not connected) tabId=\(newId, privacy: .public)" - ) - changeManager.reloadVersion += 1 - needsLazyLoad = true - } - } else { - changeManager.reloadVersion += 1 - } + changeManager.reloadVersion += 1 } else { toolbarState.isTableTab = false toolbarState.isResultsCollapsed = false - filterStateManager.clearAll() } } @@ -158,8 +117,8 @@ extension MainContentCoordinator { guard !activeTabIds.contains(tab.id), tab.execution.lastExecutedAt != nil, !tab.pendingChanges.hasChanges, - let rows = tableRowsStore.existingTableRows(for: tab.id), - !tableRowsStore.isEvicted(tab.id), + let rows = tabSessionRegistry.existingTableRows(for: tab.id), + !tabSessionRegistry.isEvicted(tab.id), !rows.rows.isEmpty else { return nil } return (tab, rows) @@ -190,7 +149,7 @@ extension MainContentCoordinator { let toEvict = sorted.dropLast(maxInactiveLoaded) for entry in toEvict { - tableRowsStore.evict(for: entry.tab.id) + tabSessionRegistry.evict(for: entry.tab.id) } Self.lifecycleLogger.debug( "[switch] evictInactiveTabs evicted=\(toEvict.count) keptInactive=\(maxInactiveLoaded) elapsedMs=\(Int(Date().timeIntervalSince(start) * 1_000))" diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift index 500d8694b..7061f2c99 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift @@ -17,25 +17,25 @@ extension MainContentCoordinator { _ mutate: (inout TableRows) -> Delta ) -> Delta { var delta: Delta = .none - tableRowsStore.updateTableRows(for: tabId) { rows in + tabSessionRegistry.updateTableRows(for: tabId) { rows in delta = mutate(&rows) } return delta } func setActiveTableRows(_ tableRows: TableRows, for tabId: UUID) { - tableRowsStore.setTableRows(tableRows, for: tabId) + tabSessionRegistry.setTableRows(tableRows, for: tabId) notifyFullReplaceIfActive(tabId: tabId) } func switchActiveResultSet(to resultSetId: UUID?, in tabId: UUID) { guard let tabIdx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } if let outgoing = tabManager.tabs[tabIdx].display.activeResultSet { - outgoing.tableRows = tableRowsStore.tableRows(for: tabId) + outgoing.tableRows = tabSessionRegistry.tableRows(for: tabId) } tabManager.tabs[tabIdx].display.activeResultSetId = resultSetId if let incoming = tabManager.tabs[tabIdx].display.activeResultSet { - tableRowsStore.setTableRows(incoming.tableRows, for: tabId) + tabSessionRegistry.setTableRows(incoming.tableRows, for: tabId) notifyFullReplaceIfActive(tabId: tabId) } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+URLFilter.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+URLFilter.swift index bc2f35b55..61ed984fd 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+URLFilter.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+URLFilter.swift @@ -17,7 +17,7 @@ extension MainContentCoordinator { isEnabled: true, rawSQL: condition ) - filterStateManager.applySingleFilter(filter) + applySingleFilter(filter) return } @@ -32,7 +32,7 @@ extension MainContentCoordinator { isSelected: true, isEnabled: true ) - filterStateManager.applySingleFilter(filter) + applySingleFilter(filter) } private func mapTablePlusOperation(_ operation: String) -> FilterOperator { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift index cb11e10ab..35992f8ff 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift @@ -3,11 +3,9 @@ // TablePro // // Window-lifecycle handlers invoked by TabWindowController's NSWindowDelegate -// methods. Replaces the global `NotificationCenter.default.publisher(for: -// NSWindow.didBecomeKeyNotification)` observers previously in MainContentView -// (one fired per ContentView instance, producing 10-14 handler invocations -// per focus change). Each window's TabWindowController now dispatches to the -// matching coordinator exactly once. +// methods. windowDidBecomeKey is intentionally lightweight (focus state + +// sidebar sync only) per Apple's documentation; visibility-scoped lazy-load +// lives in MainEditorContentView's `.task(id:)` modifier. // import AppKit @@ -19,8 +17,9 @@ extension MainContentCoordinator { // MARK: - Window Delegate Dispatch /// Called from `TabWindowController.windowDidBecomeKey(_:)`. - /// Runs lazy-load + file-based schema refresh, then invokes the view-layer - /// sidebar-sync callback set by MainContentView. + /// Updates focus state, refreshes file-based schema if stale, and syncs the + /// sidebar selection to the active tab. No query work runs here — lazy-load + /// is owned by `MainEditorContentView`'s `.task(id:)` modifier. func handleWindowDidBecomeKey() { let t0 = Date() Self.lifecycleLogger.debug( @@ -30,44 +29,16 @@ extension MainContentCoordinator { evictionTask?.cancel() evictionTask = nil - // Lazy-load: execute query for restored tabs that skipped auto-execute, - // or re-query tabs whose row data was evicted while inactive. - // Skip if the user has unsaved changes (in-memory or tab-level). - let hasPendingEdits = - changeManager.hasChanges - || (tabManager.selectedTab?.pendingChanges.hasChanges ?? false) let isConnected = DatabaseManager.shared.activeSessions[connectionId]?.isConnected ?? false - let needsLazyLoad = - tabManager.selectedTab.map { tab in - let rows = tableRowsStore.tableRows(for: tab.id) - let isEvicted = tableRowsStore.isEvicted(tab.id) - return tab.tabType == .table - && (rows.rows.isEmpty || isEvicted) - && (tab.execution.lastExecutedAt == nil || isEvicted) - && tab.execution.errorMessage == nil - && !tab.content.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } ?? false - // Skip lazy-load if this is a menu-interaction bounce (resign+become within 200ms). - let isMenuBounce = Date().timeIntervalSince(lastResignKeyDate) < 0.2 - if needsLazyLoad && !hasPendingEdits && isConnected && !isMenuBounce { - Self.lifecycleLogger.debug( - "[switch] coordinator triggering lazy runQuery connId=\(self.connectionId, privacy: .public)" - ) - runQuery() - } - let t1 = Date() - if PluginManager.shared.connectionMode(for: connection.type) == .fileBased && isConnected { Task { await self.refreshTablesIfStale() } } - let t2 = Date() - onWindowBecameKey?() - let t3 = Date() + syncSidebarToSelectedTab() Self.lifecycleLogger.debug( - "[switch] coordinator.handleWindowDidBecomeKey done connId=\(self.connectionId, privacy: .public) lazyQuery=\(Int(t1.timeIntervalSince(t0) * 1_000))ms schemaRefresh=\(Int(t2.timeIntervalSince(t1) * 1_000))ms sidebarSync=\(Int(t3.timeIntervalSince(t2) * 1_000))ms totalMs=\(Int(Date().timeIntervalSince(t0) * 1_000)) lazyLoad=\(needsLazyLoad && !hasPendingEdits && isConnected && !isMenuBounce) menuBounce=\(isMenuBounce)" + "[switch] coordinator.handleWindowDidBecomeKey done connId=\(self.connectionId, privacy: .public) totalMs=\(Int(Date().timeIntervalSince(t0) * 1_000))" ) } @@ -79,7 +50,6 @@ extension MainContentCoordinator { "[switch] coordinator.handleWindowDidResignKey connId=\(self.connectionId, privacy: .public)" ) isKeyWindow = false - lastResignKeyDate = Date() evictionTask?.cancel() evictionTask = Task { [weak self] in @@ -94,36 +64,94 @@ extension MainContentCoordinator { /// Called from `TabWindowController.windowWillClose(_:)`. /// Synchronous teardown — no grace period, no delayed Task. Writes tab - /// state to disk, invokes view-layer teardown callback, then disconnects - /// the session if this was the last window for the connection. + /// state to disk, releases SwiftUI-scoped right-panel state, then + /// disconnects the session if this was the last window for the connection. func handleWindowWillClose() { let t0 = Date() Self.lifecycleLogger.info( "[close] coordinator.handleWindowWillClose connId=\(self.connectionId, privacy: .public) tabs=\(self.tabManager.tabs.count)" ) - // Persist tabs aggregated across all windows for this connection. - // Writing this window's tabs in isolation can clobber sibling windows' - // state on disk — for example, closing an empty window would erase the - // saved tabs of an open sibling window. persistence.saveOrClearAggregatedSync() - // Cancel the pending eviction task before teardown drops it. evictionTask?.cancel() evictionTask = nil - // View-layer teardown (e.g. rightPanelState cleanup) before coordinator - // teardown so its SwiftUI state is released first. - onWindowWillClose?() + rightPanelState?.teardown() teardown() - // Disconnect is handled by WindowLifecycleMonitor.handleWindowClose, - // which fires after this delegate method. It removes the window entry - // first, then checks if any remain for the connection, then disconnects. - Self.lifecycleLogger.info( "[close] coordinator.handleWindowWillClose done connId=\(self.connectionId, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(t0) * 1_000))" ) } + + // MARK: - Sidebar Sync + + /// Update the connection-scoped sidebar selection so the active table tab + /// is highlighted. Reads tables fresh from the DatabaseManager because the + /// schema load is async and may complete after focus changes. + func syncSidebarToSelectedTab() { + let sidebarState = SharedSidebarState.forConnection(connectionId) + let liveTables = DatabaseManager.shared + .session(for: connectionId)?.tables ?? [] + let target: Set + if let currentTableName = tabManager.selectedTab?.tableContext.tableName, + let match = liveTables.first(where: { $0.name == currentTableName }) { + target = [match] + } else { + target = [] + } + if sidebarState.selectedTables != target { + if target.isEmpty && liveTables.isEmpty { return } + sidebarState.selectedTables = target + } + } + + // 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 } + + 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, + let idx = tabManager.tabs.firstIndex(where: { $0.id == tab.id }) { + tabManager.tabs[idx].execution.isExecuting = false + } else if tab.execution.isExecuting { + return + } + + guard let session = DatabaseManager.shared.session(for: connectionId), + session.isConnected else { + needsLazyLoad = true + return + } + + Self.lifecycleLogger.debug( + "[switch] coordinator.lazyLoadCurrentTabIfNeeded executing tabId=\(tab.id, privacy: .public) evicted=\(isEvicted)" + ) + executeTableTabQueryDirectly() + } } diff --git a/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift b/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift index 516776968..738cea0f4 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift @@ -16,7 +16,7 @@ extension MainContentView { guard let tab = coordinator.tabManager.selectedTab, !coordinator.selectionState.indices.isEmpty, let firstIndex = coordinator.selectionState.indices.min() else { return nil } - let tableRows = coordinator.tableRowsStore.tableRows(for: tab.id) + let tableRows = coordinator.tabSessionRegistry.tableRows(for: tab.id) guard firstIndex < tableRows.rows.count else { return nil } let row = tableRows.rows[firstIndex].values diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index 986262d1b..b52368a11 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -165,7 +165,7 @@ extension MainContentView { rightPanelState.editState.onFieldChanged = nil return } - let tableRows = coordinator.tableRowsStore.tableRows(for: tab.id) + let tableRows = coordinator.tabSessionRegistry.tableRows(for: tab.id) var allRows: [[String?]] = [] for index in selectedIndices.sorted() { @@ -225,7 +225,7 @@ extension MainContentView { let capturedEditState = rightPanelState.editState rightPanelState.editState.onFieldChanged = { columnIndex, newValue in guard let tab = capturedCoordinator.tabManager.selectedTab else { return } - let tableRows = capturedCoordinator.tableRowsStore.tableRows(for: tab.id) + let tableRows = capturedCoordinator.tabSessionRegistry.tableRows(for: tab.id) let columnName = columnIndex < tableRows.columns.count ? tableRows.columns[columnIndex] : "" @@ -268,7 +268,7 @@ extension MainContentView { let capturedCoordinator = coordinator let capturedEditState = rightPanelState.editState - let tableRows = coordinator.tableRowsStore.tableRows(for: tab.id) + let tableRows = coordinator.tabSessionRegistry.tableRows(for: tab.id) if !excludedNames.isEmpty, selectedIndices.count == 1, let tableName = tab.tableContext.tableName, diff --git a/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift b/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift index 7888826dc..be8686d00 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift @@ -101,7 +101,7 @@ extension MainContentView { private func buildQueryResultsSummary() -> String? { guard let tab = currentTab else { return nil } - let tableRows = coordinator.tableRowsStore.tableRows(for: tab.id) + let tableRows = coordinator.tabSessionRegistry.tableRows(for: tab.id) guard !tableRows.columns.isEmpty, !tableRows.rows.isEmpty else { return nil } let columns = tableRows.columns diff --git a/TablePro/Views/Main/Extensions/MainContentView+Modifiers.swift b/TablePro/Views/Main/Extensions/MainContentView+Modifiers.swift index 1da3d0631..89952b5c0 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Modifiers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Modifiers.swift @@ -61,7 +61,6 @@ struct FocusedCommandActionsModifier: ViewModifier { rightPanelState: RightPanelState(), tabManager: state.tabManager, changeManager: state.changeManager, - filterStateManager: state.filterStateManager, toolbarState: state.toolbarState, coordinator: state.coordinator ) diff --git a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index 5eebb9fdc..03383c0b5 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -271,7 +271,6 @@ extension MainContentView { func setupCommandActions() { let actions = MainContentCommandActions( coordinator: coordinator, - filterStateManager: filterStateManager, connection: connection, selectionState: coordinator.selectionState, selectedTables: Binding( diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index c35b72542..860c02cb7 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -23,7 +23,6 @@ final class MainContentCommandActions { // MARK: - Dependencies @ObservationIgnored private weak var coordinator: MainContentCoordinator? - @ObservationIgnored private let filterStateManager: FilterStateManager @ObservationIgnored private let connection: DatabaseConnection // MARK: - Bindings @@ -47,7 +46,6 @@ final class MainContentCommandActions { init( coordinator: MainContentCoordinator, - filterStateManager: FilterStateManager, connection: DatabaseConnection, selectionState: GridSelectionState, selectedTables: Binding>, @@ -57,7 +55,6 @@ final class MainContentCommandActions { rightPanelState: RightPanelState ) { self.coordinator = coordinator - self.filterStateManager = filterStateManager self.connection = connection self.selectionState = selectionState self.selectedTables = selectedTables @@ -369,7 +366,7 @@ final class MainContentCommandActions { } else { if let coordinator { for tab in coordinator.tabManager.tabs { - coordinator.tableRowsStore.removeTableRows(for: tab.id) + coordinator.tabSessionRegistry.removeTableRows(for: tab.id) } coordinator.tabManager.tabs.removeAll() coordinator.tabManager.selectedTabId = nil @@ -486,12 +483,25 @@ final class MainContentCommandActions { // MARK: - Tab Navigation (Group A — Called Directly) + /// Selects the Nth native window tab. Wrapping the `selectedWindow` + /// assignment in `NSAnimationContext.runAnimationGroup` with `duration = 0` + /// suppresses AppKit's tab-transition animation, so rapid Cmd+Number + /// presses don't queue up CAAnimations that drain visibly after the user + /// releases the keys. + /// + /// Per-switch AppKit overhead (window-focus change, NSHostingView layout, + /// Window Server roundtrip) is platform-inherent to one-NSWindow-per-tab + /// and is intentionally not coalesced. See `docs/architecture/tab-subsystem-rewrite.md` D2. func selectTab(number: Int) { guard let keyWindow = NSApp.keyWindow, - let tabbedWindows = keyWindow.tabbedWindows, - tabbedWindows.indices.contains(number - 1) else { return } - let target = tabbedWindows[number - 1] - target.makeKeyAndOrderFront(nil) + let tabGroup = keyWindow.tabGroup else { return } + let windows = tabGroup.windows + guard windows.indices.contains(number - 1) else { return } + let target = windows[number - 1] + NSAnimationContext.runAnimationGroup { context in + context.duration = 0 + tabGroup.selectedWindow = target + } } // MARK: - Filter Operations (Group A — Called Directly) @@ -499,7 +509,7 @@ final class MainContentCommandActions { func toggleFilterPanel() { guard let coordinator = coordinator, coordinator.tabManager.selectedTab?.tabType == .table else { return } - filterStateManager.toggle() + coordinator.toggleFilterPanel() } // MARK: - Data Operations (Group A — Called Directly) diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 840f398bf..61ccbed31 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -85,10 +85,9 @@ final class MainContentCoordinator { let selectionState = GridSelectionState() let tabManager: QueryTabManager let changeManager: DataChangeManager - let filterStateManager: FilterStateManager - let columnVisibilityManager: ColumnVisibilityManager let toolbarState: ConnectionToolbarState - let tableRowsStore = TableRowsStore() + let tabSessionRegistry: TabSessionRegistry + let queryExecutor: QueryExecutor // MARK: - Services @@ -177,33 +176,16 @@ final class MainContentCoordinator { /// (e.g. save-then-close). Set before calling `saveChanges`, resumed by `executeCommitStatements`. @ObservationIgnored internal var saveCompletionContinuation: CheckedContinuation? - /// Called during teardown to let the view layer release cached row providers and sort data. - @ObservationIgnored var onTeardown: (() -> Void)? - - // MARK: - Window Lifecycle (Phase 2: driven by TabWindowController NSWindowDelegate) + // MARK: - Window Lifecycle (driven by TabWindowController NSWindowDelegate) /// Whether this coordinator's window is the key (focused) window. /// Updated by TabWindowController delegate methods; consumed by /// event handlers (e.g. sidebar table-selection navigation filter). @ObservationIgnored var isKeyWindow = false - /// Timestamp of the most recent resignKey. Used by `handleWindowDidBecomeKey` - /// to detect menu-interaction bounces (resign + become within 200ms). - @ObservationIgnored var lastResignKeyDate = Date.distantPast - /// Eviction task scheduled in `handleWindowDidResignKey` (fires 5s later). @ObservationIgnored var evictionTask: Task? - /// View-layer callback invoked from `handleWindowDidBecomeKey` (e.g. sync - /// SwiftUI-scoped sidebar selection to the current tab). Set by MainContentView - /// in `.onAppear`. The callback closes over view state (@Binding tables, - /// SharedSidebarState) that isn't available to the coordinator. - @ObservationIgnored var onWindowBecameKey: (() -> Void)? - - /// View-layer callback invoked from `handleWindowWillClose` before teardown - /// (e.g. `rightPanelState.teardown()` releases SwiftUI-scoped subviewmodels). - @ObservationIgnored var onWindowWillClose: (() -> Void)? - /// True once the coordinator's view has appeared (onAppear fired). /// Coordinators that SwiftUI creates during body re-evaluation but never /// adopts into @State are silently discarded — no teardown warning needed. @@ -309,7 +291,7 @@ final class MainContentCoordinator { func evictInactiveRowData() { let selectedId = tabManager.selectedTabId for tab in tabManager.tabs where tab.id != selectedId && !tab.pendingChanges.hasChanges { - tableRowsStore.evict(for: tab.id) + tabSessionRegistry.evict(for: tab.id) } } @@ -333,17 +315,17 @@ final class MainContentCoordinator { connection: DatabaseConnection, tabManager: QueryTabManager, changeManager: DataChangeManager, - filterStateManager: FilterStateManager, - columnVisibilityManager: ColumnVisibilityManager, - toolbarState: ConnectionToolbarState + toolbarState: ConnectionToolbarState, + tabSessionRegistry: TabSessionRegistry? = nil, + queryExecutor: QueryExecutor? = nil ) { let initStart = Date() self.connection = connection self.tabManager = tabManager self.changeManager = changeManager - self.filterStateManager = filterStateManager - self.columnVisibilityManager = columnVisibilityManager self.toolbarState = toolbarState + self.tabSessionRegistry = tabSessionRegistry ?? TabSessionRegistry() + self.queryExecutor = queryExecutor ?? QueryExecutor(connection: connection) let dialect = PluginManager.shared.sqlDialect(for: connection.type) self.queryBuilder = TableQueryBuilder( databaseType: connection.type, @@ -535,15 +517,12 @@ final class MainContentCoordinator { for task in activeSortTasks.values { task.cancel() } activeSortTasks.removeAll() - onTeardown?() - onTeardown = nil - NotificationCenter.default.post( name: Self.teardownNotification, object: connection.id ) - tableRowsStore.tearDown() + tabSessionRegistry.removeAll() querySortCache.removeAll() displayFormatsCache.removeAll() cachedTableColumnTypes.removeAll() @@ -557,10 +536,8 @@ final class MainContentCoordinator { changeManager.clearChanges() changeManager.pluginDriver = nil - // Release metadata and filter state + // Release metadata tableMetadata = nil - filterStateManager.filters.removeAll() - filterStateManager.appliedFilters.removeAll() SchemaProviderRegistry.shared.release(for: connection.id) SchemaProviderRegistry.shared.purgeUnused() @@ -666,22 +643,6 @@ final class MainContentCoordinator { } } - /// Pre-compiled regex for extracting table name from SELECT queries - private static let tableNameRegex = try? NSRegularExpression( - pattern: #"(?i)^\s*SELECT\s+.+?\s+FROM\s+(?:\[([^\]]+)\]|[`"]([^`"]+)[`"]|([\w$]+))\s*(?:WHERE|ORDER|LIMIT|GROUP|HAVING|OFFSET|FETCH|$|;)"#, - options: [] - ) - - private static let mongoCollectionRegex = try? NSRegularExpression( - pattern: #"^\s*db\.(\w+)\."#, - options: [] - ) - - private static let mongoBracketCollectionRegex = try? NSRegularExpression( - pattern: #"^\s*db\["([^"]+)"\]"#, - options: [] - ) - // MARK: - Query Execution func runQuery() { @@ -922,7 +883,6 @@ final class MainContentCoordinator { } } - /// Internal query execution (called after any confirmations) internal func executeQueryInternal( _ sql: String ) { @@ -941,8 +901,6 @@ final class MainContentCoordinator { queryGeneration += 1 let capturedGeneration = queryGeneration - // Batch mutations into a single array write to avoid multiple @Published - // notifications — each notification triggers a full SwiftUI update cycle. var tab = tabManager.tabs[index] tab.execution.isExecuting = true tab.execution.executionTime = nil @@ -960,81 +918,35 @@ final class MainContentCoordinator { let tabId = tabManager.tabs[index].id let rowCap = resolveRowCap(sql: sql, tabType: tab.tabType) - let effectiveSQL = sql + let (tableName, isEditable) = resolveTableEditability(tab: tab, sql: sql) - let (tableName, isEditable) = resolveTableEditability(tab: tab, sql: effectiveSQL) + let needsMetadataFetch: Bool + if isEditable, let tableName { + needsMetadataFetch = !isMetadataCached(tabId: tabId, tableName: tableName) + } else { + needsMetadataFetch = false + } currentQueryTask = Task { [weak self] in guard let self else { return } do { - // Pre-check metadata cache before starting any queries. - var parallelSchemaTask: Task? - var needsMetadataFetch = false - - if isEditable, let tableName = tableName { - let cached = isMetadataCached(tabId: tabId, tableName: tableName) - needsMetadataFetch = !cached - - // Metadata queries run on the main driver. They serialize behind any - // in-flight query at the C-level DispatchQueue and execute immediately after. - if needsMetadataFetch { - let connId = connectionId - // Note: Schema fetch operations are not tracked by ConnectionHealthMonitor.queriesInFlight. - // This is acceptable because the health monitor checks session.isConnected before pinging, - // and schema fetches are short-lived. - parallelSchemaTask = Task { - guard let driver = DatabaseManager.shared.driver(for: connId) else { - throw DatabaseError.notConnected - } - async let cols = driver.fetchColumns(table: tableName) - async let fks = driver.fetchForeignKeys(table: tableName) - let result = try await (columnInfo: cols, fkInfo: fks) - let approxCount = try? await driver.fetchApproximateRowCount(table: tableName) - return (columnInfo: result.columnInfo, fkInfo: result.fkInfo, approximateRowCount: approxCount) - } - } - } - - // Main data query (on primary driver — runs concurrently with metadata) - guard let queryDriver = DatabaseManager.shared.driver(for: connectionId) else { - throw DatabaseError.notConnected - } - let fetchResult: QueryFetchResult - do { - fetchResult = try await Self.fetchQueryData( - driver: queryDriver, - sql: effectiveSQL, - rowCap: rowCap - ) - } - let safeColumns = fetchResult.columns - let safeColumnTypes = fetchResult.columnTypes - let safeRows = fetchResult.rows - let safeExecutionTime = fetchResult.executionTime - let safeRowsAffected = fetchResult.rowsAffected - let safeStatusMessage = fetchResult.statusMessage - let isTruncated = fetchResult.isTruncated + let executionResult = try await queryExecutor.executeQuery( + sql: sql, + parameters: nil, + rowCap: rowCap, + tableName: tableName, + fetchSchemaForTable: needsMetadataFetch + ) guard !Task.isCancelled else { - parallelSchemaTask?.cancel() - await resetExecutionState(tabId: tabId, executionTime: safeExecutionTime) - return - } - - // Await schema result before Phase 1 so data + FK arrows appear together - var schemaResult: SchemaResult? - if needsMetadataFetch { - schemaResult = await awaitSchemaResult( - parallelTask: parallelSchemaTask, - tableName: tableName ?? "" + await resetExecutionState( + tabId: tabId, + executionTime: executionResult.fetchResult.executionTime ) + return } - // Parse schema metadata if available - let metadata = schemaResult.map { self.parseSchemaMetadata($0) } - - // Phase 1: Display data rows + FK arrows in a single MainActor update. await MainActor.run { [weak self] in guard let self else { return } currentQueryTask = nil @@ -1042,9 +954,8 @@ final class MainContentCoordinator { self.clearClickHouseProgress() } toolbarState.setExecuting(false) - toolbarState.lastQueryDuration = safeExecutionTime + toolbarState.lastQueryDuration = executionResult.fetchResult.executionTime - // Always reset isExecuting even if generation is stale if capturedGeneration != queryGeneration || Task.isCancelled { if let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) { tabManager.tabs[idx].execution.isExecuting = false @@ -1054,34 +965,32 @@ final class MainContentCoordinator { applyPhase1Result( tabId: tabId, - columns: safeColumns, - columnTypes: safeColumnTypes, - rows: safeRows, - executionTime: safeExecutionTime, - rowsAffected: safeRowsAffected, - statusMessage: safeStatusMessage, + columns: executionResult.fetchResult.columns, + columnTypes: executionResult.fetchResult.columnTypes, + rows: executionResult.fetchResult.rows, + executionTime: executionResult.fetchResult.executionTime, + rowsAffected: executionResult.fetchResult.rowsAffected, + statusMessage: executionResult.fetchResult.statusMessage, tableName: tableName, isEditable: isEditable, - metadata: metadata, - hasSchema: schemaResult != nil, + metadata: executionResult.parsedMetadata, + hasSchema: executionResult.schemaResult != nil, sql: sql, connection: conn, - isTruncated: isTruncated + isTruncated: executionResult.fetchResult.isTruncated ) } - // Phase 2: Background exact COUNT + enum values. - if isEditable, let tableName = tableName { + if isEditable, let tableName { if needsMetadataFetch { launchPhase2Work( tableName: tableName, tabId: tabId, capturedGeneration: capturedGeneration, connectionType: conn.type, - schemaResult: schemaResult + schemaResult: executionResult.schemaResult ) } else { - // Metadata cached but still need exact COUNT for pagination launchPhase2Count( tableName: tableName, tabId: tabId, @@ -1098,9 +1007,6 @@ final class MainContentCoordinator { } } } catch { - // Always reset isExecuting even if generation is stale — - // skipping this leaves the tab permanently stuck in "executing" - // state, requiring a reconnect to recover. await MainActor.run { [weak self] in guard let self else { return } if let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) { @@ -1184,7 +1090,7 @@ final class MainContentCoordinator { if result.isEmpty, let createSQL = try? await driver.fetchTableDDL(table: tableName) { let columns = try? await driver.fetchColumns(table: tableName) for col in columns ?? [] { - if let values = Self.parseSQLiteCheckConstraintValues( + if let values = QuerySqlParser.parseSQLiteCheckConstraintValues( createSQL: createSQL, columnName: col.name ) { result[col.name] = values @@ -1195,68 +1101,16 @@ final class MainContentCoordinator { return result } - private static func parseSQLiteCheckConstraintValues(createSQL: String, columnName: String) -> [String]? { - let escapedName = NSRegularExpression.escapedPattern(for: columnName) - let pattern = "CHECK\\s*\\(\\s*\"?\(escapedName)\"?\\s+IN\\s*\\(([^)]+)\\)\\s*\\)" - guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) else { - return nil - } - let nsString = createSQL as NSString - guard let match = regex.firstMatch( - in: createSQL, - range: NSRange(location: 0, length: nsString.length) - ), match.numberOfRanges > 1 else { - return nil - } - let valuesString = nsString.substring(with: match.range(at: 1)) - return ColumnType.parseEnumValues(from: "ENUM(\(valuesString))") - } - // MARK: - SQL Helpers static func stripTrailingOrderBy(from sql: String) -> String { - let trimmed = sql.trimmingCharacters(in: .whitespacesAndNewlines) - let nsString = trimmed as NSString - let pattern = "\\s+ORDER\\s+BY\\s+(?![^(]*\\))[^)]*$" - guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) else { - return trimmed - } - let range = NSRange(location: 0, length: nsString.length) - return regex.stringByReplacingMatches(in: trimmed, range: range, withTemplate: "") - .trimmingCharacters(in: .whitespacesAndNewlines) + QuerySqlParser.stripTrailingOrderBy(from: sql) } // MARK: - SQL Parsing func extractTableName(from sql: String) -> String? { - let nsRange = NSRange(sql.startIndex..., in: sql) - - // SQL: SELECT ... FROM tableName (group 1 = bracket-quoted, group 2 = plain/backtick/double-quote) - if let regex = Self.tableNameRegex, - let match = regex.firstMatch(in: sql, options: [], range: nsRange) { - for group in 1...3 { - let r = match.range(at: group) - if r.location != NSNotFound, let range = Range(r, in: sql) { - return String(sql[range]) - } - } - } - - // MQL bracket notation: db["collectionName"].find(...) - if let regex = Self.mongoBracketCollectionRegex, - let match = regex.firstMatch(in: sql, options: [], range: nsRange), - let range = Range(match.range(at: 1), in: sql) { - return String(sql[range]) - } - - // MQL dot notation: db.collectionName.find(...) - if let regex = Self.mongoCollectionRegex, - let match = regex.firstMatch(in: sql, options: [], range: nsRange), - let range = Range(match.range(at: 1), in: sql) { - return String(sql[range]) - } - - return nil + QuerySqlParser.extractTableName(from: sql) } // MARK: - Sorting @@ -1264,7 +1118,7 @@ final class MainContentCoordinator { func handleSort(columnIndex: Int, ascending: Bool, isMultiSort: Bool = false) { guard let (tab, tabIndex) = tabManager.selectedTabAndIndex else { return } - let tableRows = tableRowsStore.tableRows(for: tab.id) + let tableRows = tabSessionRegistry.tableRows(for: tab.id) guard columnIndex >= 0 && columnIndex < tableRows.columns.count else { return } var currentSort = tab.sortState diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 8467e0aaf..939265500 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -45,7 +45,6 @@ struct MainContentView: View { let tabManager: QueryTabManager let changeManager: DataChangeManager - let filterStateManager: FilterStateManager let toolbarState: ConnectionToolbarState let coordinator: MainContentCoordinator @@ -77,7 +76,6 @@ struct MainContentView: View { rightPanelState: RightPanelState, tabManager: QueryTabManager, changeManager: DataChangeManager, - filterStateManager: FilterStateManager, toolbarState: ConnectionToolbarState, coordinator: MainContentCoordinator ) { @@ -91,7 +89,6 @@ struct MainContentView: View { self.rightPanelState = rightPanelState self.tabManager = tabManager self.changeManager = changeManager - self.filterStateManager = filterStateManager self.toolbarState = toolbarState self.coordinator = coordinator } @@ -181,7 +178,7 @@ struct MainContentView: View { isPresented: dismissBinding, mode: .queryResults( connection: connectionWithCurrentDatabase, - tableRows: coordinator.tableRowsStore.tableRows(for: tab.id), + tableRows: coordinator.tabSessionRegistry.tableRows(for: tab.id), suggestedFileName: fileName ) ) @@ -273,37 +270,6 @@ struct MainContentView: View { // callback on viewDidMoveToWindow, which runs AFTER SwiftUI's // onAppear in NSHostingView-hosted content.) - // Wire view-layer callbacks invoked by TabWindowController's - // NSWindowDelegate → coordinator lifecycle methods. The closures - // capture SwiftUI-scoped state (tables binding, sidebarState, - // rightPanelState) that the coordinator can't reach directly. - let connectionId = connection.id - coordinator.onWindowBecameKey = { [tabManager, sidebarState] in - // Read tables fresh from DatabaseManager every invocation — - // capturing the @Binding's wrappedValue (or `tables` - // shorthand) snapshots an empty array at onAppear time - // because the schema load is async, and the closure is - // installed once but invoked on every windowDidBecomeKey. - let liveTables = DatabaseManager.shared - .session(for: connectionId)?.tables ?? [] - let target: Set - if let currentTableName = tabManager.selectedTab?.tableContext.tableName, - let match = liveTables.first(where: { $0.name == currentTableName }) { - target = [match] - } else { - target = [] - } - if sidebarState.selectedTables != target { - // Don't clear sidebar selection while tables still loading — - // avoids double-navigation race against SidebarSyncAction. - if target.isEmpty && liveTables.isEmpty { return } - sidebarState.selectedTables = target - } - } - coordinator.onWindowWillClose = { [rightPanelState] in - rightPanelState.teardown() - } - Self.lifecycleLogger.info( "[open] MainContentView.onAppear done windowId=\(windowId, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(start) * 1_000))" ) @@ -352,7 +318,7 @@ struct MainContentView: View { handleStructureChange() } .onChange(of: currentTab?.schemaVersion) { _, _ in - let columns = currentTab.map { coordinator.tableRowsStore.tableRows(for: $0.id).columns } + let columns = currentTab.map { coordinator.tabSessionRegistry.tableRows(for: $0.id).columns } handleColumnsChange(newColumns: columns) } .task { handleConnectionStatusChange() } @@ -370,10 +336,6 @@ struct MainContentView: View { } handleTableSelectionChange(from: oldTables, to: newTables) } - // Phase 2: NSWindow.didBecomeKey / .didResignKey observers removed. - // TabWindowController's NSWindowDelegate dispatches to - // MainContentCoordinator.handleWindowDidBecomeKey / handleWindowDidResignKey - // directly — window-scoped, fires once per focus change. .onChange(of: tables) { _, newTables in let syncAction = SidebarSyncAction.resolveOnTablesLoad( newTables: newTables, @@ -396,8 +358,6 @@ struct MainContentView: View { tabManager: tabManager, coordinator: coordinator, changeManager: changeManager, - filterStateManager: filterStateManager, - columnVisibilityManager: coordinator.columnVisibilityManager, connection: connection, windowId: windowId, connectionId: connection.id, @@ -434,7 +394,7 @@ struct MainContentView: View { scheduleInspectorUpdate(lazyLoadExcludedColumns: true) }, onFilterColumn: { columnName in - filterStateManager.addFilterForColumn(columnName) + coordinator.addFilterForColumn(columnName) }, onApplyFilters: { filters in coordinator.applyFilters(filters) diff --git a/TablePro/Views/Results/ColumnVisibilityPopover.swift b/TablePro/Views/Results/ColumnVisibilityPopover.swift index e056c5ec9..ad6e89784 100644 --- a/TablePro/Views/Results/ColumnVisibilityPopover.swift +++ b/TablePro/Views/Results/ColumnVisibilityPopover.swift @@ -7,10 +7,16 @@ import SwiftUI struct ColumnVisibilityPopover: View { let columns: [String] - let columnVisibilityManager: ColumnVisibilityManager + let hiddenColumns: Set + let onToggleColumn: (String) -> Void + let onShowAll: () -> Void + let onHideAll: ([String]) -> Void @State private var searchText = "" + private var hasHiddenColumns: Bool { !hiddenColumns.isEmpty } + private var hiddenCount: Int { hiddenColumns.count } + private var filteredColumns: [String] { if searchText.isEmpty { return columns @@ -36,8 +42,8 @@ struct ColumnVisibilityPopover: View { } private var headerTitle: String { - let visible = columns.count - columnVisibilityManager.hiddenCount - if columnVisibilityManager.hasHiddenColumns { + let visible = columns.count - hiddenCount + if hasHiddenColumns { return "\(visible) of \(columns.count)" } return String(localized: "Columns") @@ -52,20 +58,20 @@ struct ColumnVisibilityPopover: View { Spacer() Button("Show All") { - columnVisibilityManager.showAll() + onShowAll() } .buttonStyle(.plain) .foregroundStyle(Color.accentColor) .controlSize(.small) - .disabled(!columnVisibilityManager.hasHiddenColumns) + .disabled(!hasHiddenColumns) Button("Hide All") { - columnVisibilityManager.hideAll(columns) + onHideAll(columns) } .buttonStyle(.plain) .foregroundStyle(Color.accentColor) .controlSize(.small) - .disabled(columnVisibilityManager.hiddenCount == columns.count) + .disabled(hiddenCount == columns.count) } .padding(.horizontal, 12) .padding(.vertical, 8) @@ -91,8 +97,8 @@ struct ColumnVisibilityPopover: View { private func columnRow(_ column: String) -> some View { Toggle(isOn: Binding( - get: { !columnVisibilityManager.hiddenColumns.contains(column) }, - set: { _ in columnVisibilityManager.toggleColumn(column) } + get: { !hiddenColumns.contains(column) }, + set: { _ in onToggleColumn(column) } )) { Text(column) .lineLimit(1) diff --git a/TableProTests/Core/Database/MultiConnectionTests.swift b/TableProTests/Core/Database/MultiConnectionTests.swift index 6ad99ad1e..77978ea77 100644 --- a/TableProTests/Core/Database/MultiConnectionTests.swift +++ b/TableProTests/Core/Database/MultiConnectionTests.swift @@ -179,15 +179,12 @@ struct CoordinatorConnectionIsolationTests { let connection = TestFixtures.makeConnection(id: connId, name: "MySQL", database: "db_a", type: .mysql) let tabManager = QueryTabManager() let changeManager = DataChangeManager() - let filterStateManager = FilterStateManager() let toolbarState = ConnectionToolbarState() let coordinator = MainContentCoordinator( connection: connection, tabManager: tabManager, changeManager: changeManager, - filterStateManager: filterStateManager, - columnVisibilityManager: ColumnVisibilityManager(), toolbarState: toolbarState ) defer { coordinator.teardown() } @@ -206,8 +203,6 @@ struct CoordinatorConnectionIsolationTests { connection: conn1, tabManager: QueryTabManager(), changeManager: DataChangeManager(), - filterStateManager: FilterStateManager(), - columnVisibilityManager: ColumnVisibilityManager(), toolbarState: ConnectionToolbarState() ) defer { coordinator1.teardown() } @@ -216,8 +211,6 @@ struct CoordinatorConnectionIsolationTests { connection: conn2, tabManager: QueryTabManager(), changeManager: DataChangeManager(), - filterStateManager: FilterStateManager(), - columnVisibilityManager: ColumnVisibilityManager(), toolbarState: ConnectionToolbarState() ) defer { coordinator2.teardown() } @@ -251,15 +244,12 @@ struct CoordinatorConnectionIsolationTests { let connection = TestFixtures.makeConnection(id: connId, name: "MySQL", database: "db_a", type: .mysql) let tabManager = QueryTabManager() let changeManager = DataChangeManager() - let filterStateManager = FilterStateManager() let toolbarState = ConnectionToolbarState() let coordinator = MainContentCoordinator( connection: connection, tabManager: tabManager, changeManager: changeManager, - filterStateManager: filterStateManager, - columnVisibilityManager: ColumnVisibilityManager(), toolbarState: toolbarState ) defer { coordinator.teardown() } diff --git a/TableProTests/Core/Services/Query/QueryExecutorTests.swift b/TableProTests/Core/Services/Query/QueryExecutorTests.swift new file mode 100644 index 000000000..96347316a --- /dev/null +++ b/TableProTests/Core/Services/Query/QueryExecutorTests.swift @@ -0,0 +1,229 @@ +// +// QueryExecutorTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("QueryExecutor") +@MainActor +struct QueryExecutorTests { + // MARK: - SQL parsing (delegates to QuerySqlParser) + + @Test("extractTableName parses bareword FROM clause") + func extractTableNameBareword() { + let name = QuerySqlParser.extractTableName(from: "SELECT * FROM users WHERE id = 1") + #expect(name == "users") + } + + @Test("extractTableName parses backtick-quoted table") + func extractTableNameBackticks() { + let name = QuerySqlParser.extractTableName(from: "SELECT * FROM `User Logs`") + #expect(name == "User Logs") + } + + @Test("extractTableName parses double-quoted table") + func extractTableNameDoubleQuotes() { + let name = QuerySqlParser.extractTableName(from: "SELECT * FROM \"public.user\"") + #expect(name == "public.user") + } + + @Test("extractTableName parses MSSQL-style bracket-quoted table") + func extractTableNameBracketQuotes() { + let name = QuerySqlParser.extractTableName(from: "SELECT id FROM [Users] WHERE id = 1") + #expect(name == "Users") + } + + @Test("extractTableName parses MQL dot notation") + func extractTableNameMQLDot() { + let name = QuerySqlParser.extractTableName(from: "db.users.find({})") + #expect(name == "users") + } + + @Test("extractTableName parses MQL bracket notation") + func extractTableNameMQLBracket() { + let name = QuerySqlParser.extractTableName(from: #"db["user logs"].find({})"#) + #expect(name == "user logs") + } + + @Test("extractTableName returns nil when no FROM clause") + func extractTableNameNoMatch() { + #expect(QuerySqlParser.extractTableName(from: "SHOW TABLES") == nil) + #expect(QuerySqlParser.extractTableName(from: "CREATE TABLE foo (id INT)") == nil) + } + + @Test("stripTrailingOrderBy removes a trailing ORDER BY clause") + func stripTrailingOrderByRemovesClause() { + let stripped = QuerySqlParser.stripTrailingOrderBy(from: "SELECT * FROM users ORDER BY id DESC") + #expect(stripped == "SELECT * FROM users") + } + + @Test("stripTrailingOrderBy preserves SQL without ORDER BY") + func stripTrailingOrderByPreservesUnchanged() { + let stripped = QuerySqlParser.stripTrailingOrderBy(from: "SELECT * FROM users WHERE id > 1") + #expect(stripped == "SELECT * FROM users WHERE id > 1") + } + + @Test("stripTrailingOrderBy does not strip ORDER BY inside subquery") + func stripTrailingOrderByIgnoresInsideParens() { + let original = "SELECT id FROM (SELECT id FROM users ORDER BY id) AS sub" + let stripped = QuerySqlParser.stripTrailingOrderBy(from: original) + #expect(stripped == original) + } + + @Test("parseSQLiteCheckConstraintValues extracts IN-list values") + func parseSQLiteCheckExtracts() { + let ddl = "CREATE TABLE t (status TEXT CHECK(\"status\" IN ('a','b','c')))" + let values = QuerySqlParser.parseSQLiteCheckConstraintValues(createSQL: ddl, columnName: "status") + #expect(values == ["a", "b", "c"]) + } + + @Test("parseSQLiteCheckConstraintValues returns nil when constraint missing") + func parseSQLiteCheckMissing() { + let ddl = "CREATE TABLE t (status TEXT)" + let values = QuerySqlParser.parseSQLiteCheckConstraintValues(createSQL: ddl, columnName: "status") + #expect(values == nil) + } + + // MARK: - DDL detection + + @Test("isDDLStatement recognizes CREATE/DROP/ALTER/TRUNCATE/RENAME") + func isDDLStatementPositive() { + #expect(QueryExecutor.isDDLStatement("CREATE TABLE foo (id INT)")) + #expect(QueryExecutor.isDDLStatement("DROP TABLE foo")) + #expect(QueryExecutor.isDDLStatement("alter table foo add column bar int")) + #expect(QueryExecutor.isDDLStatement(" TRUNCATE foo")) + #expect(QueryExecutor.isDDLStatement("RENAME TABLE foo TO bar")) + } + + @Test("isDDLStatement returns false for SELECT, INSERT, UPDATE, DELETE") + func isDDLStatementNegative() { + #expect(!QueryExecutor.isDDLStatement("SELECT 1")) + #expect(!QueryExecutor.isDDLStatement("INSERT INTO foo VALUES (1)")) + #expect(!QueryExecutor.isDDLStatement("UPDATE foo SET x = 1")) + #expect(!QueryExecutor.isDDLStatement("DELETE FROM foo")) + } + + // MARK: - Parameter detection + + @Test("detectAndReconcileParameters returns empty when SQL has no placeholders") + func detectParamsNoPlaceholders() { + let result = QueryExecutor.detectAndReconcileParameters( + sql: "SELECT * FROM users", + existing: [] + ) + #expect(result.isEmpty) + } + + @Test("detectAndReconcileParameters preserves existing values for matching names") + func detectParamsPreservesExistingValues() { + let existing = [ + QueryParameter(name: "user_id", value: "42", type: .integer) + ] + let result = QueryExecutor.detectAndReconcileParameters( + sql: "SELECT * FROM users WHERE id = :user_id", + existing: existing + ) + #expect(result.count == 1) + #expect(result[0].name == "user_id") + #expect(result[0].value == "42") + #expect(result[0].type == .integer) + } + + @Test("detectAndReconcileParameters drops parameters no longer in SQL") + func detectParamsDropsRemoved() { + let existing = [ + QueryParameter(name: "old", value: "x"), + QueryParameter(name: "kept", value: "y") + ] + let result = QueryExecutor.detectAndReconcileParameters( + sql: "SELECT * FROM t WHERE c = :kept", + existing: existing + ) + #expect(result.map(\.name) == ["kept"]) + #expect(result[0].value == "y") + } + + @Test("detectAndReconcileParameters adds new parameters with empty values") + func detectParamsAddsNew() { + let result = QueryExecutor.detectAndReconcileParameters( + sql: "SELECT * FROM t WHERE a = :a AND b = :b", + existing: [] + ) + #expect(result.map(\.name) == ["a", "b"]) + #expect(result.allSatisfy { $0.value.isEmpty }) + } + + // MARK: - Schema metadata parsing + + @Test("parseSchemaMetadata maps columns, foreign keys, primary keys") + func parseSchemaMetadataMapsFields() { + let columns = [ + ColumnInfo( + name: "id", dataType: "INT", isNullable: false, isPrimaryKey: true, + defaultValue: nil, extra: nil, charset: nil, collation: nil, comment: nil + ), + ColumnInfo( + name: "name", dataType: "VARCHAR(255)", isNullable: true, isPrimaryKey: false, + defaultValue: "guest", extra: nil, charset: nil, collation: nil, comment: nil + ) + ] + let fks = [ + ForeignKeyInfo( + name: "fk_role", column: "role_id", + referencedTable: "roles", referencedColumn: "id" + ) + ] + let schema: SchemaResult = (columnInfo: columns, fkInfo: fks, approximateRowCount: 1_234) + + let parsed = QueryExecutor.parseSchemaMetadata(schema) + + #expect(parsed.primaryKeyColumns == ["id"]) + #expect(parsed.columnDefaults["id"] == .some(nil)) + #expect(parsed.columnDefaults["name"] == .some("guest")) + #expect(parsed.columnNullable["id"] == false) + #expect(parsed.columnNullable["name"] == true) + #expect(parsed.columnForeignKeys["role_id"]?.referencedTable == "roles") + #expect(parsed.approximateRowCount == 1_234) + } + + @Test("parseSchemaMetadata extracts MySQL-style ENUM values") + func parseSchemaMetadataExtractsEnumValues() { + let columns = [ + ColumnInfo( + name: "status", + dataType: "ENUM('open','closed','archived')", + isNullable: false, isPrimaryKey: false, + defaultValue: nil, extra: nil, charset: nil, collation: nil, comment: nil + ) + ] + let schema: SchemaResult = (columnInfo: columns, fkInfo: [], approximateRowCount: nil) + + let parsed = QueryExecutor.parseSchemaMetadata(schema) + + #expect(parsed.columnEnumValues["status"] == ["open", "closed", "archived"]) + } + + @Test("parseSchemaMetadata returns empty containers when input is empty") + func parseSchemaMetadataEmpty() { + let schema: SchemaResult = (columnInfo: [], fkInfo: [], approximateRowCount: nil) + let parsed = QueryExecutor.parseSchemaMetadata(schema) + #expect(parsed.primaryKeyColumns.isEmpty) + #expect(parsed.columnDefaults.isEmpty) + #expect(parsed.columnNullable.isEmpty) + #expect(parsed.columnForeignKeys.isEmpty) + #expect(parsed.columnEnumValues.isEmpty) + #expect(parsed.approximateRowCount == nil) + } + + // TODO: integration test for `QueryExecutor.executeQuery` orchestration + // (parallel schema fetch, cancel-on-fetch-error, parameterised path). + // Requires either a `DatabaseDriver` mock registered with + // `DatabaseManager.shared` or a DI refactor of `QueryExecutor` to accept + // an injected driver. Static helpers above already cover SQL parsing, + // metadata parsing, parameter reconciliation, DDL detection, and row-cap + // policy. +} diff --git a/TableProTests/Core/Services/Query/TableRowsStoreTests.swift b/TableProTests/Core/Services/Query/TabSessionRegistryTableRowsTests.swift similarity index 88% rename from TableProTests/Core/Services/Query/TableRowsStoreTests.swift rename to TableProTests/Core/Services/Query/TabSessionRegistryTableRowsTests.swift index c2be639d4..527fbf246 100644 --- a/TableProTests/Core/Services/Query/TableRowsStoreTests.swift +++ b/TableProTests/Core/Services/Query/TabSessionRegistryTableRowsTests.swift @@ -1,14 +1,13 @@ import Foundation -import Testing @testable import TablePro +import Testing -@Suite("TableRowsStore") +@Suite("TabSessionRegistry+TableRows") @MainActor -struct TableRowsStoreTests { - - @Test("tableRows(for:) creates empty TableRows on first access and returns the same on second") +struct TabSessionRegistryTableRowsTests { + @Test("tableRows(for:) returns empty TableRows on first access without creating a session") func tableRowsCreatesAndReturnsSameValue() { - let store = TableRowsStore() + let store = TabSessionRegistry() let tabId = UUID() let first = store.tableRows(for: tabId) @@ -23,7 +22,7 @@ struct TableRowsStoreTests { @Test("setTableRows(_:for:) replaces stored value") func setTableRowsReplacesEntry() { - let store = TableRowsStore() + let store = TabSessionRegistry() let tabId = UUID() _ = store.tableRows(for: tabId) @@ -41,7 +40,7 @@ struct TableRowsStoreTests { @Test("existingTableRows(for:) returns nil before set and value after") func existingTableRowsReflectsState() { - let store = TableRowsStore() + let store = TabSessionRegistry() let tabId = UUID() #expect(store.existingTableRows(for: tabId) == nil) @@ -60,7 +59,7 @@ struct TableRowsStoreTests { @Test("removeTableRows(for:) deletes the entry and clears evicted state") func removeTableRowsDeletes() { - let store = TableRowsStore() + let store = TabSessionRegistry() let tabId = UUID() store.setTableRows( @@ -77,7 +76,7 @@ struct TableRowsStoreTests { @Test("evict(for:) clears rows and marks evicted while preserving columns") func evictMarksEvicted() { - let store = TableRowsStore() + let store = TabSessionRegistry() let tabId = UUID() let rows = TableRows.from( queryRows: [["a"], ["b"]], @@ -97,13 +96,13 @@ struct TableRowsStoreTests { @Test("evict(for:) is no-op for unknown tab") func evictUnknownTabIsNoOp() { - let store = TableRowsStore() + let store = TabSessionRegistry() store.evict(for: UUID()) } @Test("evictAll(except:) evicts other tabs and spares the active one") func evictAllSparesActive() { - let store = TableRowsStore() + let store = TabSessionRegistry() let activeId = UUID() let otherId1 = UUID() let otherId2 = UUID() @@ -127,7 +126,7 @@ struct TableRowsStoreTests { @Test("evictAll(except: nil) evicts every loaded tab") func evictAllNoActiveEvictsAll() { - let store = TableRowsStore() + let store = TabSessionRegistry() let id1 = UUID() let id2 = UUID() store.setTableRows( @@ -147,7 +146,7 @@ struct TableRowsStoreTests { @Test("evictAll(except:) skips empty tables") func evictAllSkipsEmpty() { - let store = TableRowsStore() + let store = TabSessionRegistry() let tabId = UUID() store.setTableRows(TableRows(), for: tabId) @@ -157,7 +156,7 @@ struct TableRowsStoreTests { @Test("setTableRows clears evicted flag") func setClearsEvicted() { - let store = TableRowsStore() + let store = TabSessionRegistry() let tabId = UUID() store.setTableRows( TableRows.from(queryRows: [["a"]], columns: ["c"], columnTypes: [.text(rawType: nil)]), @@ -175,7 +174,7 @@ struct TableRowsStoreTests { @Test("updateTableRows applies mutation in place") func updateTableRowsAppliesMutation() { - let store = TableRowsStore() + let store = TabSessionRegistry() let tabId = UUID() store.setTableRows( TableRows.from(queryRows: [["a"]], columns: ["c"], columnTypes: [.text(rawType: nil)]), @@ -190,9 +189,9 @@ struct TableRowsStoreTests { #expect(resolved?.value(at: 0, column: 0) == "z") } - @Test("closing one tab removes only its TableRows entry, leaving siblings intact") + @Test("removing one tab leaves siblings intact") func closingTabRemovesOnlyThatEntry() { - let store = TableRowsStore() + let store = TabSessionRegistry() let tabId1 = UUID() let tabId2 = UUID() @@ -211,9 +210,9 @@ struct TableRowsStoreTests { #expect(store.existingTableRows(for: tabId2)?.rows.count == 1) } - @Test("tearDown() clears the store") - func tearDownClearsAll() { - let store = TableRowsStore() + @Test("removeAll() clears the registry") + func removeAllClearsAll() { + let store = TabSessionRegistry() let id1 = UUID() let id2 = UUID() store.setTableRows( @@ -225,7 +224,7 @@ struct TableRowsStoreTests { for: id2 ) - store.tearDown() + store.removeAll() #expect(store.existingTableRows(for: id1) == nil) #expect(store.existingTableRows(for: id2) == nil) diff --git a/TableProTests/Core/Storage/ColumnVisibilityPersistenceTests.swift b/TableProTests/Core/Storage/ColumnVisibilityPersistenceTests.swift new file mode 100644 index 000000000..64aa1a26f --- /dev/null +++ b/TableProTests/Core/Storage/ColumnVisibilityPersistenceTests.swift @@ -0,0 +1,149 @@ +// +// ColumnVisibilityPersistenceTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("ColumnVisibilityPersistence") +@MainActor +struct ColumnVisibilityPersistenceTests { + private func makeDefaults() -> UserDefaults { + let suiteName = "ColumnVisibilityPersistenceTests-\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + fatalError("Failed to create UserDefaults suite for tests") + } + return defaults + } + + @Test("loadHiddenColumns returns an empty set when no value is stored") + func loadReturnsEmptyByDefault() { + let defaults = makeDefaults() + let result = ColumnVisibilityPersistence.loadHiddenColumns( + for: "users", + connectionId: UUID(), + defaults: defaults + ) + #expect(result.isEmpty) + } + + @Test("saveHiddenColumns then loadHiddenColumns round-trips the set") + func roundTripsAcrossSaveAndLoad() { + let defaults = makeDefaults() + let connectionId = UUID() + ColumnVisibilityPersistence.saveHiddenColumns( + ["email", "phone"], + for: "users", + connectionId: connectionId, + defaults: defaults + ) + + let result = ColumnVisibilityPersistence.loadHiddenColumns( + for: "users", + connectionId: connectionId, + defaults: defaults + ) + #expect(result == ["email", "phone"]) + } + + @Test("Different tables under the same connection store independent sets") + func tablesAreScopedSeparately() { + let defaults = makeDefaults() + let connectionId = UUID() + ColumnVisibilityPersistence.saveHiddenColumns( + ["a"], + for: "users", + connectionId: connectionId, + defaults: defaults + ) + ColumnVisibilityPersistence.saveHiddenColumns( + ["b"], + for: "orders", + connectionId: connectionId, + defaults: defaults + ) + + #expect( + ColumnVisibilityPersistence.loadHiddenColumns( + for: "users", + connectionId: connectionId, + defaults: defaults + ) == ["a"] + ) + #expect( + ColumnVisibilityPersistence.loadHiddenColumns( + for: "orders", + connectionId: connectionId, + defaults: defaults + ) == ["b"] + ) + } + + @Test("Different connections store independent sets for the same table name") + func connectionsAreScopedSeparately() { + let defaults = makeDefaults() + let connectionA = UUID() + let connectionB = UUID() + ColumnVisibilityPersistence.saveHiddenColumns( + ["x"], + for: "users", + connectionId: connectionA, + defaults: defaults + ) + ColumnVisibilityPersistence.saveHiddenColumns( + ["y"], + for: "users", + connectionId: connectionB, + defaults: defaults + ) + + #expect( + ColumnVisibilityPersistence.loadHiddenColumns( + for: "users", + connectionId: connectionA, + defaults: defaults + ) == ["x"] + ) + #expect( + ColumnVisibilityPersistence.loadHiddenColumns( + for: "users", + connectionId: connectionB, + defaults: defaults + ) == ["y"] + ) + } + + @Test("saveHiddenColumns with an empty set persists as an empty array") + func savingEmptySetClearsState() { + let defaults = makeDefaults() + let connectionId = UUID() + ColumnVisibilityPersistence.saveHiddenColumns( + ["leftover"], + for: "users", + connectionId: connectionId, + defaults: defaults + ) + ColumnVisibilityPersistence.saveHiddenColumns( + [], + for: "users", + connectionId: connectionId, + defaults: defaults + ) + + let result = ColumnVisibilityPersistence.loadHiddenColumns( + for: "users", + connectionId: connectionId, + defaults: defaults + ) + #expect(result.isEmpty) + } + + @Test("Storage key encodes connection id and table name") + func keyFormat() { + let connectionId = UUID() + let key = ColumnVisibilityPersistence.key(tableName: "users", connectionId: connectionId) + #expect(key == "com.TablePro.columns.hiddenColumns.\(connectionId.uuidString).users") + } +} diff --git a/TableProTests/Models/Query/TabSessionRegistryTests.swift b/TableProTests/Models/Query/TabSessionRegistryTests.swift new file mode 100644 index 000000000..24ede107b --- /dev/null +++ b/TableProTests/Models/Query/TabSessionRegistryTests.swift @@ -0,0 +1,86 @@ +// +// TabSessionRegistryTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("TabSessionRegistry") +@MainActor +struct TabSessionRegistryTests { + @Test("session(for:) returns nil for an unregistered id") + func sessionForUnregisteredIdIsNil() { + let registry = TabSessionRegistry() + #expect(registry.session(for: UUID()) == nil) + } + + @Test("register stores the session by id") + func registerStoresSession() { + let registry = TabSessionRegistry() + let session = TabSession() + + registry.register(session) + + #expect(registry.session(for: session.id) === session) + } + + @Test("register replaces an existing session for the same id") + func registerReplacesExisting() { + let registry = TabSessionRegistry() + let id = UUID() + let first = TabSession(id: id) + let second = TabSession(id: id) + + registry.register(first) + registry.register(second) + + #expect(registry.session(for: id) === second) + } + + @Test("unregister removes the entry") + func unregisterRemovesEntry() { + let registry = TabSessionRegistry() + let session = TabSession() + registry.register(session) + + registry.unregister(id: session.id) + + #expect(registry.session(for: session.id) == nil) + } + + @Test("unregister of an unknown id is a no-op") + func unregisterUnknownIdIsNoOp() { + let registry = TabSessionRegistry() + registry.unregister(id: UUID()) + } + + @Test("removeAll clears every registered session") + func removeAllClearsAll() { + let registry = TabSessionRegistry() + let first = TabSession() + let second = TabSession() + registry.register(first) + registry.register(second) + + registry.removeAll() + + #expect(registry.session(for: first.id) == nil) + #expect(registry.session(for: second.id) == nil) + } + + @Test("Multiple sessions coexist under distinct ids") + func multipleSessionsCoexist() { + let registry = TabSessionRegistry() + let first = TabSession() + let second = TabSession() + + registry.register(first) + registry.register(second) + + #expect(registry.session(for: first.id) === first) + #expect(registry.session(for: second.id) === second) + } +} diff --git a/TableProTests/Models/Query/TabSessionTests.swift b/TableProTests/Models/Query/TabSessionTests.swift new file mode 100644 index 000000000..52fe547ec --- /dev/null +++ b/TableProTests/Models/Query/TabSessionTests.swift @@ -0,0 +1,186 @@ +// +// TabSessionTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("TabSession") +@MainActor +struct TabSessionTests { + private func makeQueryTab( + title: String = "Tab", + query: String = "SELECT 1", + tabType: TabType = .query, + tableName: String? = nil + ) -> QueryTab { + QueryTab(title: title, query: query, tabType: tabType, tableName: tableName) + } + + // MARK: - Initialization + + @Test("Init from QueryTab preserves id and all primitive fields") + func initFromQueryTabPreservesIdentity() { + var tab = makeQueryTab(title: "Users", query: "SELECT * FROM users", tabType: .table, tableName: "users") + tab.tableContext.databaseName = "main" + tab.tableContext.isEditable = true + tab.execution.lastExecutedAt = Date(timeIntervalSince1970: 1_000) + tab.schemaVersion = 7 + + let session = TabSession(queryTab: tab) + + #expect(session.id == tab.id) + #expect(session.title == "Users") + #expect(session.tabType == .table) + #expect(session.content.query == "SELECT * FROM users") + #expect(session.tableContext.tableName == "users") + #expect(session.tableContext.databaseName == "main") + #expect(session.tableContext.isEditable == true) + #expect(session.execution.lastExecutedAt == Date(timeIntervalSince1970: 1_000)) + #expect(session.schemaVersion == 7) + } + + @Test("Init with primitives produces same defaults as QueryTab.init") + func initPrimitivesMatchesQueryTabDefaults() { + let id = UUID() + let session = TabSession(id: id, title: "Q", query: "x", tabType: .query, tableName: nil) + let tab = QueryTab(id: id, title: "Q", query: "x", tabType: .query, tableName: nil) + + #expect(session.id == tab.id) + #expect(session.title == tab.title) + #expect(session.tabType == tab.tabType) + #expect(session.content.query == tab.content.query) + #expect(session.isPreview == tab.isPreview) + #expect(session.schemaVersion == tab.schemaVersion) + #expect(session.hasUserInteraction == tab.hasUserInteraction) + } + + // MARK: - Conversion roundtrip + + @Test("snapshot() returns a QueryTab equal to the source") + func snapshotRoundtripEqualsSource() { + var original = makeQueryTab(title: "Orders", query: "SELECT * FROM orders", tabType: .table, tableName: "orders") + original.tableContext.primaryKeyColumns = ["id"] + original.execution.rowsAffected = 42 + original.pagination.currentPage = 3 + original.pagination.pageSize = 100 + original.sortState.columns = [SortColumn(columnIndex: 0, direction: .descending)] + + let session = TabSession(queryTab: original) + let roundtrip = session.snapshot() + + #expect(roundtrip == original) + } + + @Test("absorb() updates fields and preserves id") + func absorbReplacesState() { + let id = UUID() + let initial = QueryTab(id: id, title: "v1", query: "SELECT 1", tabType: .query) + let session = TabSession(queryTab: initial) + + var updated = QueryTab(id: id, title: "v2", query: "SELECT 2", tabType: .table, tableName: "users") + updated.schemaVersion = 9 + + session.absorb(updated) + + #expect(session.id == id) + #expect(session.title == "v2") + #expect(session.content.query == "SELECT 2") + #expect(session.tabType == .table) + #expect(session.tableContext.tableName == "users") + #expect(session.schemaVersion == 9) + } + + // MARK: - Reference semantics + + @Test("Multiple references see the same mutations (class semantics)") + func sharedReferenceSemantics() { + let session = TabSession(queryTab: makeQueryTab()) + let alias = session + + alias.title = "renamed" + alias.schemaVersion = 5 + + #expect(session.title == "renamed") + #expect(session.schemaVersion == 5) + } + + @Test("Snapshot decouples from the live session (value semantics on the snapshot)") + func snapshotIsDecoupled() { + let session = TabSession(queryTab: makeQueryTab(title: "live")) + var taken = session.snapshot() + + taken.title = "snapshot-only" + + #expect(session.title == "live") + #expect(taken.title == "snapshot-only") + } + + // MARK: - Session-only state defaults + + @Test("tableRows defaults to an empty TableRows on primitive init") + func tableRowsDefaultsEmptyOnPrimitiveInit() { + let session = TabSession() + #expect(session.tableRows.rows.isEmpty) + #expect(session.tableRows.columns.isEmpty) + } + + @Test("tableRows defaults to an empty TableRows when lifted from QueryTab") + func tableRowsDefaultsEmptyFromQueryTab() { + let session = TabSession(queryTab: makeQueryTab()) + #expect(session.tableRows.rows.isEmpty) + #expect(session.tableRows.columns.isEmpty) + } + + @Test("isEvicted defaults to false") + func isEvictedDefaultsFalse() { + #expect(TabSession().isEvicted == false) + #expect(TabSession(queryTab: makeQueryTab()).isEvicted == false) + } + + @Test("loadEpoch defaults to zero") + func loadEpochDefaultsZero() { + #expect(TabSession().loadEpoch == 0) + #expect(TabSession(queryTab: makeQueryTab()).loadEpoch == 0) + } + + // MARK: - loadEpoch round-trip + + @Test("loadEpoch round-trips through snapshot()") + func loadEpochRoundTripsThroughSnapshot() { + let session = TabSession(queryTab: makeQueryTab()) + session.loadEpoch = 7 + + let snapshot = session.snapshot() + + #expect(snapshot.loadEpoch == 7) + } + + @Test("loadEpoch round-trips through absorb()") + func loadEpochRoundTripsThroughAbsorb() { + let id = UUID() + let initial = QueryTab(id: id) + let session = TabSession(queryTab: initial) + + var updated = QueryTab(id: id) + updated.loadEpoch = 12 + session.absorb(updated) + + #expect(session.loadEpoch == 12) + } + + @Test("loadEpoch survives a snapshot/absorb roundtrip across sessions") + func loadEpochSurvivesRoundtrip() { + let id = UUID() + let session1 = TabSession(queryTab: QueryTab(id: id)) + session1.loadEpoch = 3 + + let snapshot = session1.snapshot() + let session2 = TabSession(queryTab: snapshot) + + #expect(session2.loadEpoch == 3) + } +} diff --git a/TableProTests/Models/UI/ColumnVisibilityManagerTests.swift b/TableProTests/Models/UI/ColumnVisibilityManagerTests.swift deleted file mode 100644 index bc2780a93..000000000 --- a/TableProTests/Models/UI/ColumnVisibilityManagerTests.swift +++ /dev/null @@ -1,204 +0,0 @@ -// -// ColumnVisibilityManagerTests.swift -// TableProTests -// - -import Foundation -import Testing - -@testable import TablePro - -@Suite("ColumnVisibilityManager") -@MainActor -struct ColumnVisibilityManagerTests { - @Test("Initial state has no hidden columns") - func initialState() { - let manager = ColumnVisibilityManager() - #expect(manager.hiddenColumns.isEmpty) - #expect(!manager.hasHiddenColumns) - #expect(manager.hiddenCount == 0) - } - - @Test("hideColumn adds column to hidden set") - func hideColumn() { - let manager = ColumnVisibilityManager() - manager.hideColumn("name") - #expect(manager.hiddenColumns.contains("name")) - #expect(manager.hiddenCount == 1) - } - - @Test("showColumn removes column from hidden set") - func showColumn() { - let manager = ColumnVisibilityManager() - manager.hideColumn("name") - manager.showColumn("name") - #expect(!manager.hiddenColumns.contains("name")) - #expect(manager.hiddenCount == 0) - } - - @Test("toggleColumn hides visible column and shows hidden column") - func toggleColumn() { - let manager = ColumnVisibilityManager() - - manager.toggleColumn("name") - #expect(manager.hiddenColumns.contains("name")) - - manager.toggleColumn("name") - #expect(!manager.hiddenColumns.contains("name")) - } - - @Test("showAll clears all hidden columns") - func showAll() { - let manager = ColumnVisibilityManager() - manager.hideColumn("a") - manager.hideColumn("b") - manager.hideColumn("c") - - manager.showAll() - #expect(manager.hiddenColumns.isEmpty) - #expect(manager.hiddenCount == 0) - } - - @Test("hideAll hides all given columns") - func hideAll() { - let manager = ColumnVisibilityManager() - manager.hideAll(["a", "b", "c"]) - #expect(manager.hiddenColumns == Set(["a", "b", "c"])) - #expect(manager.hiddenCount == 3) - } - - @Test("hideAll then showAll round-trip") - func hideAllThenShowAll() { - let manager = ColumnVisibilityManager() - manager.hideAll(["x", "y", "z"]) - #expect(manager.hasHiddenColumns) - - manager.showAll() - #expect(!manager.hasHiddenColumns) - #expect(manager.hiddenColumns.isEmpty) - } - - @Test("hasHiddenColumns reflects state correctly") - func hasHiddenColumns() { - let manager = ColumnVisibilityManager() - #expect(!manager.hasHiddenColumns) - - manager.hideColumn("id") - #expect(manager.hasHiddenColumns) - - manager.showColumn("id") - #expect(!manager.hasHiddenColumns) - } - - @Test("hiddenCount returns correct count") - func hiddenCount() { - let manager = ColumnVisibilityManager() - #expect(manager.hiddenCount == 0) - - manager.hideColumn("a") - #expect(manager.hiddenCount == 1) - - manager.hideColumn("b") - #expect(manager.hiddenCount == 2) - - manager.showColumn("a") - #expect(manager.hiddenCount == 1) - } - - @Test("saveToColumnLayout and restoreFromColumnLayout round-trip") - func columnLayoutRoundTrip() { - let manager = ColumnVisibilityManager() - manager.hideColumn("col1") - manager.hideColumn("col2") - - let saved = manager.saveToColumnLayout() - - let other = ColumnVisibilityManager() - other.restoreFromColumnLayout(saved) - #expect(other.hiddenColumns == Set(["col1", "col2"])) - } - - @Test("restoreFromColumnLayout replaces state instead of merging") - func restoreReplacesState() { - let manager = ColumnVisibilityManager() - manager.hideColumn("existing") - - manager.restoreFromColumnLayout(Set(["new1", "new2"])) - #expect(manager.hiddenColumns == Set(["new1", "new2"])) - #expect(!manager.hiddenColumns.contains("existing")) - } - - @Test("pruneStaleColumns removes columns not in current set") - func pruneStaleColumns() { - let manager = ColumnVisibilityManager() - manager.hideAll(["a", "b", "c", "d"]) - - manager.pruneStaleColumns(["b", "d", "e"]) - #expect(manager.hiddenColumns == Set(["b", "d"])) - } - - @Test("pruneStaleColumns with empty current columns clears all hidden") - func pruneStaleColumnsEmptyCurrent() { - let manager = ColumnVisibilityManager() - manager.hideAll(["a", "b", "c"]) - - manager.pruneStaleColumns([]) - #expect(manager.hiddenColumns.isEmpty) - } - - @Test("pruneStaleColumns with no stale columns keeps all hidden") - func pruneStaleColumnsNoStale() { - let manager = ColumnVisibilityManager() - manager.hideAll(["a", "b"]) - - manager.pruneStaleColumns(["a", "b", "c"]) - #expect(manager.hiddenColumns == Set(["a", "b"])) - } - - @Test("UserDefaults round-trip for saveLastHiddenColumns and restoreLastHiddenColumns") - func userDefaultsRoundTrip() { - let tableName = "test_table_\(UUID().uuidString)" - let connectionId = UUID() - let key = "com.TablePro.columns.hiddenColumns.\(connectionId.uuidString).\(tableName)" - defer { UserDefaults.standard.removeObject(forKey: key) } - - let manager = ColumnVisibilityManager() - manager.hideAll(["col1", "col2", "col3"]) - manager.saveLastHiddenColumns(for: tableName, connectionId: connectionId) - - let other = ColumnVisibilityManager() - other.restoreLastHiddenColumns(for: tableName, connectionId: connectionId) - #expect(other.hiddenColumns == Set(["col1", "col2", "col3"])) - } - - @Test("restoreLastHiddenColumns with no saved data resets to empty") - func restoreWithNoSavedData() { - let tableName = "nonexistent_table_\(UUID().uuidString)" - let connectionId = UUID() - let key = "com.TablePro.columns.hiddenColumns.\(connectionId.uuidString).\(tableName)" - defer { UserDefaults.standard.removeObject(forKey: key) } - - let manager = ColumnVisibilityManager() - manager.hideColumn("leftover") - - manager.restoreLastHiddenColumns(for: tableName, connectionId: connectionId) - #expect(manager.hiddenColumns.isEmpty) - } - - @Test("hideColumn is idempotent when hiding same column twice") - func hideColumnIdempotent() { - let manager = ColumnVisibilityManager() - manager.hideColumn("name") - manager.hideColumn("name") - #expect(manager.hiddenCount == 1) - #expect(manager.hiddenColumns.contains("name")) - } - - @Test("showColumn on non-hidden column is a no-op") - func showColumnNonHidden() { - let manager = ColumnVisibilityManager() - manager.showColumn("nonexistent") - #expect(manager.hiddenColumns.isEmpty) - #expect(manager.hiddenCount == 0) - } -} diff --git a/TableProTests/Views/Main/CommandActionsDispatchTests.swift b/TableProTests/Views/Main/CommandActionsDispatchTests.swift index a8bc5c7dd..d6a30978b 100644 --- a/TableProTests/Views/Main/CommandActionsDispatchTests.swift +++ b/TableProTests/Views/Main/CommandActionsDispatchTests.swift @@ -28,7 +28,6 @@ struct CommandActionsDispatchTests { let actions = MainContentCommandActions( coordinator: coordinator, - filterStateManager: state.filterStateManager, connection: connection, selectionState: coordinator.selectionState, selectedTables: Binding(get: { selectedTables }, set: { selectedTables = $0 }), diff --git a/TableProTests/Views/Main/CoordinatorColumnVisibilityTests.swift b/TableProTests/Views/Main/CoordinatorColumnVisibilityTests.swift new file mode 100644 index 000000000..f52e49a9a --- /dev/null +++ b/TableProTests/Views/Main/CoordinatorColumnVisibilityTests.swift @@ -0,0 +1,129 @@ +// +// CoordinatorColumnVisibilityTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("MainContentCoordinator column visibility helpers") +@MainActor +struct CoordinatorColumnVisibilityTests { + private func makeCoordinator() -> (MainContentCoordinator, QueryTabManager) { + let tabManager = QueryTabManager() + let coordinator = MainContentCoordinator( + connection: TestFixtures.makeConnection(), + tabManager: tabManager, + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + return (coordinator, tabManager) + } + + private func addTableTab( + to tabManager: QueryTabManager, + tableName: String + ) -> UUID { + var tab = QueryTab( + title: tableName, + query: "SELECT * FROM \(tableName)", + tabType: .table, + tableName: tableName + ) + tab.tableContext.isEditable = true + tab.execution.lastExecutedAt = Date() + tabManager.tabs.append(tab) + tabManager.selectedTabId = tab.id + return tab.id + } + + @Test("hideColumn inserts into the active tab's hidden set") + func hideColumn() { + let (coordinator, tabManager) = makeCoordinator() + let tabId = addTableTab(to: tabManager, tableName: "users") + + coordinator.hideColumn("name") + + guard let index = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { + Issue.record("Expected tab to exist") + return + } + #expect(tabManager.tabs[index].columnLayout.hiddenColumns == ["name"]) + } + + @Test("showColumn removes from the active tab's hidden set") + func showColumn() { + let (coordinator, tabManager) = makeCoordinator() + _ = addTableTab(to: tabManager, tableName: "users") + coordinator.hideColumn("name") + coordinator.hideColumn("email") + + coordinator.showColumn("name") + + #expect(coordinator.selectedTabHiddenColumns == ["email"]) + } + + @Test("toggleColumnVisibility flips state") + func toggleColumnVisibility() { + let (coordinator, tabManager) = makeCoordinator() + _ = addTableTab(to: tabManager, tableName: "users") + + coordinator.toggleColumnVisibility("name") + #expect(coordinator.selectedTabHiddenColumns.contains("name")) + + coordinator.toggleColumnVisibility("name") + #expect(!coordinator.selectedTabHiddenColumns.contains("name")) + } + + @Test("showAllColumns clears hidden set on the active tab") + func showAllColumns() { + let (coordinator, tabManager) = makeCoordinator() + _ = addTableTab(to: tabManager, tableName: "users") + coordinator.hideAllColumns(["a", "b", "c"]) + + coordinator.showAllColumns() + #expect(coordinator.selectedTabHiddenColumns.isEmpty) + } + + @Test("hideAllColumns replaces the hidden set with the supplied columns") + func hideAllColumns() { + let (coordinator, tabManager) = makeCoordinator() + _ = addTableTab(to: tabManager, tableName: "users") + coordinator.hideColumn("legacy") + + coordinator.hideAllColumns(["one", "two"]) + #expect(coordinator.selectedTabHiddenColumns == ["one", "two"]) + } + + @Test("pruneHiddenColumns drops names not in the current set") + func pruneHiddenColumns() { + let (coordinator, tabManager) = makeCoordinator() + _ = addTableTab(to: tabManager, tableName: "users") + coordinator.hideAllColumns(["a", "b", "c", "d"]) + + coordinator.pruneHiddenColumns(currentColumns: ["b", "d", "e"]) + #expect(coordinator.selectedTabHiddenColumns == ["b", "d"]) + } + + @Test("hideColumn is idempotent") + func hideColumnIdempotent() { + let (coordinator, tabManager) = makeCoordinator() + _ = addTableTab(to: tabManager, tableName: "users") + + coordinator.hideColumn("name") + coordinator.hideColumn("name") + #expect(coordinator.selectedTabHiddenColumns == ["name"]) + } + + @Test("hideColumn mirrors into the corresponding TabSession") + func hideColumnMirrorsIntoSession() { + let (coordinator, tabManager) = makeCoordinator() + let tabId = addTableTab(to: tabManager, tableName: "users") + + coordinator.hideColumn("name") + + let session = coordinator.tabSessionRegistry.session(for: tabId) + #expect(session?.columnLayout.hiddenColumns == ["name"]) + } +} diff --git a/TableProTests/Views/Main/CoordinatorEditorLoadTests.swift b/TableProTests/Views/Main/CoordinatorEditorLoadTests.swift index eae248e64..f6a8e40a4 100644 --- a/TableProTests/Views/Main/CoordinatorEditorLoadTests.swift +++ b/TableProTests/Views/Main/CoordinatorEditorLoadTests.swift @@ -20,15 +20,12 @@ struct CoordinatorEditorLoadTests { let connection = TestFixtures.makeConnection(database: "testdb") let tabManager = QueryTabManager() let changeManager = DataChangeManager() - let filterStateManager = FilterStateManager() let toolbarState = ConnectionToolbarState() let coordinator = MainContentCoordinator( connection: connection, tabManager: tabManager, changeManager: changeManager, - filterStateManager: filterStateManager, - columnVisibilityManager: ColumnVisibilityManager(), toolbarState: toolbarState ) return (coordinator, tabManager) diff --git a/TableProTests/Views/Main/CoordinatorReloadSidebarTests.swift b/TableProTests/Views/Main/CoordinatorReloadSidebarTests.swift index f92afe6e5..41c296498 100644 --- a/TableProTests/Views/Main/CoordinatorReloadSidebarTests.swift +++ b/TableProTests/Views/Main/CoordinatorReloadSidebarTests.swift @@ -19,15 +19,12 @@ struct CoordinatorRefreshTablesTests { let connection = TestFixtures.makeConnection(database: "db_a") let tabManager = QueryTabManager() let changeManager = DataChangeManager() - let filterStateManager = FilterStateManager() let toolbarState = ConnectionToolbarState() let coordinator = MainContentCoordinator( connection: connection, tabManager: tabManager, changeManager: changeManager, - filterStateManager: filterStateManager, - columnVisibilityManager: ColumnVisibilityManager(), toolbarState: toolbarState ) defer { coordinator.teardown() } @@ -45,15 +42,12 @@ struct CoordinatorRefreshTablesTests { let connection = TestFixtures.makeConnection(database: "db_a") let tabManager = QueryTabManager() let changeManager = DataChangeManager() - let filterStateManager = FilterStateManager() let toolbarState = ConnectionToolbarState() let coordinator = MainContentCoordinator( connection: connection, tabManager: tabManager, changeManager: changeManager, - filterStateManager: filterStateManager, - columnVisibilityManager: ColumnVisibilityManager(), toolbarState: toolbarState ) defer { coordinator.teardown() } diff --git a/TableProTests/Views/Main/CoordinatorSidebarActionsTests.swift b/TableProTests/Views/Main/CoordinatorSidebarActionsTests.swift index 1ae5d99d0..043fe3e4f 100644 --- a/TableProTests/Views/Main/CoordinatorSidebarActionsTests.swift +++ b/TableProTests/Views/Main/CoordinatorSidebarActionsTests.swift @@ -25,15 +25,12 @@ struct CoordinatorSidebarActionsTests { connection.safeModeLevel = safeModeLevel let tabManager = QueryTabManager() let changeManager = DataChangeManager() - let filterStateManager = FilterStateManager() let toolbarState = ConnectionToolbarState() let coordinator = MainContentCoordinator( connection: connection, tabManager: tabManager, changeManager: changeManager, - filterStateManager: filterStateManager, - columnVisibilityManager: ColumnVisibilityManager(), toolbarState: toolbarState ) return (coordinator, tabManager) diff --git a/TableProTests/Views/Main/EvictionTests.swift b/TableProTests/Views/Main/EvictionTests.swift index 5bb20c441..e96528b76 100644 --- a/TableProTests/Views/Main/EvictionTests.swift +++ b/TableProTests/Views/Main/EvictionTests.swift @@ -6,8 +6,8 @@ // import Foundation -import Testing @testable import TablePro +import Testing @Suite("Cross-Window Tab Eviction") @MainActor @@ -15,15 +15,12 @@ struct EvictionTests { private func makeCoordinator() -> (MainContentCoordinator, QueryTabManager) { let tabManager = QueryTabManager() let changeManager = DataChangeManager() - let filterStateManager = FilterStateManager() let toolbarState = ConnectionToolbarState() let connection = TestFixtures.makeConnection() let coordinator = MainContentCoordinator( connection: connection, tabManager: tabManager, changeManager: changeManager, - filterStateManager: filterStateManager, - columnVisibilityManager: ColumnVisibilityManager(), toolbarState: toolbarState ) return (coordinator, tabManager) @@ -53,13 +50,13 @@ struct EvictionTests { // Add a second tab so the first becomes background (eviction skips the selected tab) addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "orders") - #expect(coordinator.tableRowsStore.tableRows(for: backgroundTabId).rows.count == 10) - #expect(coordinator.tableRowsStore.isEvicted(backgroundTabId) == false) + #expect(coordinator.tabSessionRegistry.tableRows(for: backgroundTabId).rows.count == 10) + #expect(coordinator.tabSessionRegistry.isEvicted(backgroundTabId) == false) coordinator.evictInactiveRowData() - #expect(coordinator.tableRowsStore.isEvicted(backgroundTabId) == true) - #expect(coordinator.tableRowsStore.tableRows(for: backgroundTabId).rows.isEmpty) + #expect(coordinator.tabSessionRegistry.isEvicted(backgroundTabId) == true) + #expect(coordinator.tabSessionRegistry.tableRows(for: backgroundTabId).rows.isEmpty) } @Test("evictInactiveRowData skips tabs with pending changes") @@ -72,8 +69,8 @@ struct EvictionTests { coordinator.evictInactiveRowData() let tabId = tabManager.tabs[0].id - #expect(coordinator.tableRowsStore.isEvicted(tabId) == false) - #expect(coordinator.tableRowsStore.tableRows(for: tabId).rows.count == 10) + #expect(coordinator.tabSessionRegistry.isEvicted(tabId) == false) + #expect(coordinator.tabSessionRegistry.tableRows(for: tabId).rows.count == 10) } @Test("evictInactiveRowData preserves column metadata after eviction") @@ -85,9 +82,9 @@ struct EvictionTests { coordinator.evictInactiveRowData() - let rows = coordinator.tableRowsStore.tableRows(for: backgroundTabId) + let rows = coordinator.tabSessionRegistry.tableRows(for: backgroundTabId) #expect(rows.columns == ["id", "name", "email"]) - #expect(coordinator.tableRowsStore.isEvicted(backgroundTabId) == true) + #expect(coordinator.tabSessionRegistry.isEvicted(backgroundTabId) == true) } @Test("evictInactiveRowData with no tabs is no-op") diff --git a/TableProTests/Views/Main/ExtractTableNameTests.swift b/TableProTests/Views/Main/ExtractTableNameTests.swift index ec2766f7c..ae5b71e66 100644 --- a/TableProTests/Views/Main/ExtractTableNameTests.swift +++ b/TableProTests/Views/Main/ExtractTableNameTests.swift @@ -18,15 +18,12 @@ struct ExtractTableNameTests { let connection = TestFixtures.makeConnection(database: "db_a") let tabManager = QueryTabManager() let changeManager = DataChangeManager() - let filterStateManager = FilterStateManager() let toolbarState = ConnectionToolbarState() return MainContentCoordinator( connection: connection, tabManager: tabManager, changeManager: changeManager, - filterStateManager: filterStateManager, - columnVisibilityManager: ColumnVisibilityManager(), toolbarState: toolbarState ) } diff --git a/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift b/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift new file mode 100644 index 000000000..96a770e52 --- /dev/null +++ b/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift @@ -0,0 +1,248 @@ +// +// MainContentCoordinatorLazyLoadTests.swift +// TableProTests +// +// Tests for lazyLoadCurrentTabIfNeeded — the Apple-pattern visibility-scoped +// lazy-load entry point invoked by MainEditorContentView's `.task(id:)` +// modifier. Replaces the old in-line lazy-load block in handleWindowDidBecomeKey +// and handleTabChange. +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("MainContentCoordinator lazyLoadCurrentTabIfNeeded") +@MainActor +struct MainContentCoordinatorLazyLoadTests { + private func makeCoordinator() -> (MainContentCoordinator, QueryTabManager) { + let tabManager = QueryTabManager() + let coordinator = MainContentCoordinator( + connection: TestFixtures.makeConnection(), + tabManager: tabManager, + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + return (coordinator, tabManager) + } + + private func addTableTab( + to tabManager: QueryTabManager, + tableName: String = "users", + query: String = "SELECT * FROM users" + ) -> UUID { + var tab = QueryTab( + title: tableName, + query: query, + tabType: .table, + tableName: tableName + ) + tab.tableContext.isEditable = true + tabManager.tabs.append(tab) + tabManager.selectedTabId = tab.id + return tab.id + } + + private func addQueryTab( + to tabManager: QueryTabManager, + title: String = "Query 1", + query: String = "SELECT 1" + ) -> UUID { + let tab = QueryTab(title: title, query: query, tabType: .query) + tabManager.tabs.append(tab) + tabManager.selectedTabId = tab.id + return tab.id + } + + private func seedRows( + _ coordinator: MainContentCoordinator, + for tabId: UUID, + columns: [String] = ["id", "name"], + rowCount: Int = 3 + ) { + let rows = (0..