diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ba4e2567..48e89423c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Internal: AI chat tools declare their access mode (read-only, write, agent-only) rather than relying on a hardcoded allowlist; new tools are picked up automatically - AI providers: Anthropic test connection uses the configured model, known model list updated through Claude 4.7, and Ollama detection now logs the actual error category instead of swallowing every failure as 'not running' - AI Chat views: replace custom pill buttons with native `.borderless` styles, switch hardcoded text colors to semantic system colors, use relative font sizing in Markdown rendering, align spacing to the 8-pt grid, and add accessibility labels to icon-only buttons +- Translucent backgrounds (Welcome sidebar, settings banners, ER diagram toolbar, JSON editor controls, Pro feature scrim) honor the system Reduce Transparency and Increase Contrast accessibility settings, swapping the material for a solid surface color when either is on ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 9a2d81a53..495394be4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,7 +109,7 @@ When adding a new method to the driver protocol: add to `PluginDatabaseDriver` ( - **`SQLEditorTheme`** — single source of truth for editor colors/fonts - **`TableProEditorTheme`** — adapter to CodeEdit's `EditorTheme` protocol - **`CompletionEngine`** — framework-agnostic; **`SQLCompletionAdapter`** bridges to CodeEdit's `CodeSuggestionDelegate` -- **`EditorTabBar`** — pure SwiftUI tab bar +- Editor tabs use native NSWindow tabs (`NSWindow.tabbingMode = .preferred` in `TabWindowController`); there is no custom tab bar. - Cursor model: `cursorPositions: [CursorPosition]` (multi-cursor via CodeEditSourceEditor) ### Change Tracking Flow diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index 013785b9c..25a9b0c38 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -116,6 +116,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } + func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + true + } + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { let hasUnsaved = MainContentCoordinator.hasAnyUnsavedChanges() if hasUnsaved { diff --git a/TablePro/Core/ChangeTracking/DataChangeManager.swift b/TablePro/Core/ChangeTracking/DataChangeManager.swift index d25e50b65..1ae101981 100644 --- a/TablePro/Core/ChangeTracking/DataChangeManager.swift +++ b/TablePro/Core/ChangeTracking/DataChangeManager.swift @@ -56,11 +56,7 @@ final class DataChangeManager: ChangeManaging { var databaseType: DatabaseType = .mysql var pluginDriver: (any PluginDatabaseDriver)? - private var _columnsStorage: [String] = [] - var columns: [String] { - get { _columnsStorage } - set { _columnsStorage = newValue.map { String($0) } } - } + var columns: [String] = [] var undoManagerProvider: (() -> UndoManager?)? var onUndoApplied: ((UndoResult) -> Void)? diff --git a/TablePro/Core/DataGrid/RowDisplayBox.swift b/TablePro/Core/DataGrid/RowDisplayBox.swift new file mode 100644 index 000000000..9b571d14d --- /dev/null +++ b/TablePro/Core/DataGrid/RowDisplayBox.swift @@ -0,0 +1,31 @@ +// +// RowDisplayBox.swift +// TablePro +// + +import Foundation + +final class RowIDKey: NSObject { + let id: RowID + + init(_ id: RowID) { + self.id = id + super.init() + } + + override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? RowIDKey else { return false } + return other.id == id + } + + override var hash: Int { id.hashValue } +} + +final class RowDisplayBox: NSObject { + var values: ContiguousArray + + init(_ values: ContiguousArray) { + self.values = values + super.init() + } +} diff --git a/TablePro/Core/Database/DatabaseManager+Sessions.swift b/TablePro/Core/Database/DatabaseManager+Sessions.swift index 62825d917..ee6d39caa 100644 --- a/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -137,8 +137,6 @@ extension DatabaseManager { setSession(session, for: connection.id) } - appSettingsStorage.saveLastConnectionId(connection.id) - MacAnalyticsProvider.shared.markConnectionSucceeded() AppEvents.shared.databaseDidConnect.send(DatabaseDidConnect(connectionId: connection.id)) @@ -345,7 +343,6 @@ extension DatabaseManager { switchToSession(nextSessionId) } else { currentSessionId = nil - appSettingsStorage.saveLastConnectionId(nil) } } lifecycleLogger.info( diff --git a/TablePro/Core/Database/DatabaseManager.swift b/TablePro/Core/Database/DatabaseManager.swift index 1d90eaf74..d48cff06d 100644 --- a/TablePro/Core/Database/DatabaseManager.swift +++ b/TablePro/Core/Database/DatabaseManager.swift @@ -26,7 +26,6 @@ final class DatabaseManager { didSet { if Set(oldValue.keys) != Set(activeSessions.keys) { connectionListVersion &+= 1 - persistOpenConnectionIds() } connectionStatusVersion &+= 1 } @@ -105,10 +104,4 @@ final class DatabaseManager { self.pluginManager = pluginManager } - private func persistOpenConnectionIds() { - let connections = connectionStorage.loadConnections() - let activeKeys = Set(activeSessions.keys) - let ids = connections.filter { activeKeys.contains($0.id) }.map(\.id) - appSettingsStorage.saveLastOpenConnectionIds(ids) - } } diff --git a/TablePro/Core/Plugins/QueryResultExportDataSource.swift b/TablePro/Core/Plugins/QueryResultExportDataSource.swift index 4706f55c7..4bd2201de 100644 --- a/TablePro/Core/Plugins/QueryResultExportDataSource.swift +++ b/TablePro/Core/Plugins/QueryResultExportDataSource.swift @@ -22,7 +22,7 @@ final class QueryResultExportDataSource: PluginExportDataSource, @unchecked Send self.driver = driver self.columns = tableRows.columns self.columnTypeNames = tableRows.columnTypes.map { $0.rawType ?? "" } - self.rows = tableRows.rows.map(\.values) + self.rows = tableRows.rows.map { Array($0.values) } } func streamRows(table: String, databaseName: String) -> AsyncThrowingStream { diff --git a/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift b/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift index e3114be40..66b9a601c 100644 --- a/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift +++ b/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift @@ -115,30 +115,12 @@ internal final class AppLaunchCoordinator { } private func runStartupBehaviorIfNeeded(skipping intents: [LaunchIntent]) { - guard intents.isEmpty else { - closeRestoredMainWindowsExcept(intents: intents) - return - } + guard intents.isEmpty else { return } + let general = AppSettingsStorage.shared.loadGeneral() - guard general.startupBehavior == .reopenLast else { - closeRestoredMainWindowsExcept(intents: intents) - return - } - let openIds = AppSettingsStorage.shared.loadLastOpenConnectionIds() - if !openIds.isEmpty { - attemptAutoReconnect(connectionIds: openIds) - return - } - if let lastId = AppSettingsStorage.shared.loadLastConnectionId() { - attemptAutoReconnect(connectionIds: [lastId]) - return - } - Task { [weak self] in - let diskIds = await TabDiskActor.shared.connectionIdsWithSavedState() - if !diskIds.isEmpty { - self?.attemptAutoReconnect(connectionIds: diskIds) - } else { - self?.closeRestoredMainWindowsExcept(intents: []) + if general.startupBehavior == .showWelcome { + for window in NSApp.windows where Self.isMainWindow(window) { + window.close() } } } @@ -149,60 +131,6 @@ internal final class AppLaunchCoordinator { showWelcomeWindow() } - private func closeRestoredMainWindowsExcept(intents: [LaunchIntent]) { - let preserved = Set(intents.compactMap { $0.targetConnectionId }) - for window in NSApp.windows where Self.isMainWindow(window) { - if let id = WindowLifecycleMonitor.shared.connectionId(forWindow: window), - preserved.contains(id) { - continue - } - window.close() - } - } - - private func attemptAutoReconnect(connectionIds: [UUID]) { - let saved = ConnectionStorage.shared.loadConnections() - let valid = connectionIds.compactMap { id in - saved.first(where: { $0.id == id }) - } - guard !valid.isEmpty else { - AppSettingsStorage.shared.saveLastOpenConnectionIds([]) - AppSettingsStorage.shared.saveLastConnectionId(nil) - closeRestoredMainWindowsExcept(intents: []) - showWelcomeWindow() - return - } - for window in NSApp.windows where Self.isWelcomeWindow(window) { - window.orderOut(nil) - } - Task { [weak self] in - for connection in valid { - let payload = EditorTabPayload( - connectionId: connection.id, intent: .restoreOrDefault - ) - WindowManager.shared.openTab(payload: payload) - do { - try await DatabaseManager.shared.ensureConnected(connection) - } catch is CancellationError { - for window in WindowLifecycleMonitor.shared.windows(for: connection.id) { - window.close() - } - } catch { - Self.logger.error("Auto-reconnect failed for '\(connection.name, privacy: .public)': \(error.localizedDescription, privacy: .public)") - for window in WindowLifecycleMonitor.shared.windows(for: connection.id) { - window.close() - } - } - } - for window in NSApp.windows where Self.isWelcomeWindow(window) { - window.close() - } - if !NSApp.windows.contains(where: { Self.isMainWindow($0) && $0.isVisible }) { - self?.showWelcomeWindow() - } - } - } - // MARK: - Window Identification internal static func isMainWindow(_ window: NSWindow) -> Bool { diff --git a/TablePro/Core/Services/Infrastructure/TabWindowController.swift b/TablePro/Core/Services/Infrastructure/TabWindowController.swift index a331a9257..f1338a78b 100644 --- a/TablePro/Core/Services/Infrastructure/TabWindowController.swift +++ b/TablePro/Core/Services/Infrastructure/TabWindowController.swift @@ -58,7 +58,8 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { ) window.identifier = NSUserInterfaceItemIdentifier("main") window.minSize = NSSize(width: 720, height: 480) - window.isRestorable = false + window.isRestorable = AppSettingsStorage.shared.loadGeneral().startupBehavior == .reopenLast + window.restorationClass = TabWindowRestoration.self window.toolbarStyle = .unified window.titleVisibility = .hidden window.tabbingMode = .preferred @@ -93,6 +94,11 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { fatalError("TabWindowController does not support NSCoder init") } + override func encodeRestorableState(with coder: NSCoder) { + super.encodeRestorableState(with: coder) + coder.encode(payload.connectionId.uuidString as NSString, forKey: TabWindowRestoration.connectionIdKey) + } + // MARK: - NSWindowDelegate func windowWillReturnFieldEditor(_ sender: NSWindow, to client: Any?) -> Any? { diff --git a/TablePro/Core/Services/Infrastructure/TabWindowRestoration.swift b/TablePro/Core/Services/Infrastructure/TabWindowRestoration.swift new file mode 100644 index 000000000..d35d4706a --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/TabWindowRestoration.swift @@ -0,0 +1,81 @@ +// +// TabWindowRestoration.swift +// TablePro +// + +import AppKit +import os + +@MainActor +final class TabWindowRestoration: NSObject, NSWindowRestoration { + private nonisolated static let logger = Logger(subsystem: "com.TablePro", category: "WindowRestoration") + nonisolated static let connectionIdKey = "TablePro.connectionId" + + nonisolated static func restoreWindow( + withIdentifier identifier: NSUserInterfaceItemIdentifier, + state: NSCoder, + completionHandler: @escaping (NSWindow?, Error?) -> Void + ) { + let uuidString = state.decodeObject(of: NSString.self, forKey: connectionIdKey) as String? + + Task { @MainActor in + guard let uuidString, + let connectionId = UUID(uuidString: uuidString) else { + logger.warning("[restore] Missing or invalid connectionId in state") + completionHandler(nil, restorationError(.missingConnectionId)) + return + } + + let connections = ConnectionStorage.shared.loadConnections() + guard let connection = connections.first(where: { $0.id == connectionId }) else { + logger.warning("[restore] Connection \(uuidString, privacy: .public) no longer exists") + completionHandler(nil, restorationError(.connectionNotFound)) + return + } + + let payload = EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault) + WindowManager.shared.openTab(payload: payload) + + let restored = NSApp.windows.first { candidate in + guard candidate.isVisible, + let controller = candidate.windowController as? TabWindowController + else { return false } + return controller.payload.connectionId == connection.id + } + + if let restored { + logger.info( + "[restore] connId=\(connection.id, privacy: .public) name=\(connection.name, privacy: .public)" + ) + completionHandler(restored, nil) + + Task { + do { + try await DatabaseManager.shared.ensureConnected(connection) + } catch { + logger.error( + "[restore] connect failed for \(connection.id, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) + } + } + } else { + logger.error("[restore] WindowManager opened tab but no window found") + completionHandler(nil, restorationError(.windowNotCreated)) + } + } + } + + private enum RestorationFailure: Int { + case missingConnectionId = 1 + case connectionNotFound = 2 + case windowNotCreated = 3 + } + + private nonisolated static func restorationError(_ failure: RestorationFailure) -> NSError { + NSError( + domain: "com.TablePro.WindowRestoration", + code: failure.rawValue, + userInfo: [NSLocalizedDescriptionKey: "Window restoration failed (\(failure))"] + ) + } +} diff --git a/TablePro/Core/Services/Query/RowOperationsManager.swift b/TablePro/Core/Services/Query/RowOperationsManager.swift index eb909fc2a..2f62cb420 100644 --- a/TablePro/Core/Services/Query/RowOperationsManager.swift +++ b/TablePro/Core/Services/Query/RowOperationsManager.swift @@ -75,7 +75,7 @@ final class RowOperationsManager { ) -> AddNewRowResult? { guard sourceRowIndex >= 0, sourceRowIndex < tableRows.count else { return nil } - var newValues = tableRows.rows[sourceRowIndex].values + var newValues = Array(tableRows.rows[sourceRowIndex].values) for pkColumn in changeManager.primaryKeyColumns { if let pkIndex = columns.firstIndex(of: pkColumn), pkIndex < newValues.count { @@ -110,7 +110,7 @@ final class RowOperationsManager { insertedRowsToDelete.append(rowIndex) } else if !changeManager.isRowDeleted(rowIndex) { if rowIndex < tableRows.count { - existingRowsToDelete.append((rowIndex: rowIndex, originalRow: tableRows.rows[rowIndex].values)) + existingRowsToDelete.append((rowIndex: rowIndex, originalRow: Array(tableRows.rows[rowIndex].values))) } } } diff --git a/TablePro/Core/Storage/AppSettingsStorage.swift b/TablePro/Core/Storage/AppSettingsStorage.swift index c48195850..1a705ac3c 100644 --- a/TablePro/Core/Storage/AppSettingsStorage.swift +++ b/TablePro/Core/Storage/AppSettingsStorage.swift @@ -32,8 +32,6 @@ final class AppSettingsStorage { static let sync = "com.TablePro.settings.sync" static let terminal = "com.TablePro.settings.terminal" static let mcp = "com.TablePro.settings.mcp" - static let lastConnectionId = "com.TablePro.settings.lastConnectionId" - static let lastOpenConnectionIds = "com.TablePro.settings.lastOpenConnectionIds" static let hasCompletedOnboarding = "com.TablePro.settings.hasCompletedOnboarding" } @@ -152,44 +150,6 @@ final class AppSettingsStorage { save(settings, key: Keys.mcp) } - // MARK: - Last Connection (for Reopen Last Session) - - /// Load the last used connection ID - func loadLastConnectionId() -> UUID? { - guard let uuidString = defaults.string(forKey: Keys.lastConnectionId) else { - return nil - } - return UUID(uuidString: uuidString) - } - - /// Save the last used connection ID - func saveLastConnectionId(_ connectionId: UUID?) { - if let connectionId = connectionId { - defaults.set(connectionId.uuidString, forKey: Keys.lastConnectionId) - } else { - defaults.removeObject(forKey: Keys.lastConnectionId) - } - } - - // MARK: - Last Open Connections (for multi-session restore) - - /// Load all connection IDs that were open when the app last quit - func loadLastOpenConnectionIds() -> [UUID] { - guard let strings = defaults.stringArray(forKey: Keys.lastOpenConnectionIds) else { - return [] - } - return strings.compactMap { UUID(uuidString: $0) } - } - - /// Save all currently open connection IDs for restoration on next launch - func saveLastOpenConnectionIds(_ connectionIds: [UUID]) { - if connectionIds.isEmpty { - defaults.removeObject(forKey: Keys.lastOpenConnectionIds) - } else { - defaults.set(connectionIds.map(\.uuidString), forKey: Keys.lastOpenConnectionIds) - } - } - // MARK: - Last Selected Database (per connection) func saveLastDatabase(_ database: String?, for connectionId: UUID) { diff --git a/TablePro/Core/Storage/TabDiskActor.swift b/TablePro/Core/Storage/TabDiskActor.swift index 25e81f7a1..5dc153782 100644 --- a/TablePro/Core/Storage/TabDiskActor.swift +++ b/TablePro/Core/Storage/TabDiskActor.swift @@ -82,28 +82,6 @@ internal actor TabDiskActor { } } - internal func connectionIdsWithSavedState() -> [UUID] { - let fm = FileManager.default - guard let files = try? fm.contentsOfDirectory( - at: tabStateDirectory, - includingPropertiesForKeys: nil - ) else { - return [] - } - var validIds: [UUID] = [] - for url in files where url.pathExtension == "json" { - guard let id = UUID(uuidString: url.deletingPathExtension().lastPathComponent) else { continue } - if let data = try? Data(contentsOf: url), - let state = try? decoder.decode(TabDiskState.self, from: data), - !state.tabs.isEmpty { - validIds.append(id) - } else { - try? fm.removeItem(at: url) - } - } - return validIds - } - // MARK: - Static Path Helpers nonisolated private static func resolvedTabStateDirectory() -> URL { diff --git a/TablePro/Models/Query/Row.swift b/TablePro/Models/Query/Row.swift index b40fc16d8..86b50df40 100644 --- a/TablePro/Models/Query/Row.swift +++ b/TablePro/Models/Query/Row.swift @@ -17,7 +17,7 @@ enum RowID: Hashable, Sendable { struct Row: Equatable, Sendable { var id: RowID - var values: [String?] + var values: ContiguousArray subscript(column: Int) -> String? { get { column >= 0 && column < values.count ? values[column] : nil } diff --git a/TablePro/Models/Query/TableRows.swift b/TablePro/Models/Query/TableRows.swift index 81d1842aa..2d918993d 100644 --- a/TablePro/Models/Query/TableRows.swift +++ b/TablePro/Models/Query/TableRows.swift @@ -7,6 +7,7 @@ import Foundation struct TableRows: Sendable { var rows: ContiguousArray + private(set) var indexByID: [RowID: Int] var columns: [String] var columnTypes: [ColumnType] var columnDefaults: [String: String?] @@ -24,6 +25,7 @@ struct TableRows: Sendable { columnNullable: [String: Bool] = [:] ) { self.rows = rows + self.indexByID = Self.buildIndex(for: rows) self.columns = columns self.columnTypes = columnTypes self.columnDefaults = columnDefaults @@ -40,14 +42,11 @@ struct TableRows: Sendable { } func index(of id: RowID) -> Int? { - for (index, row) in rows.enumerated() where row.id == id { - return index - } - return nil + indexByID[id] } func row(withID id: RowID) -> Row? { - guard let index = index(of: id) else { return nil } + guard let index = indexByID[id] else { return nil } return rows[index] } @@ -80,8 +79,10 @@ struct TableRows: Sendable { mutating func appendInsertedRow(values: [String?]) -> Delta { let normalized = Self.normalize(values: values, toCount: columns.count) let row = Row(id: .inserted(UUID()), values: normalized) + let newIndex = rows.count rows.append(row) - return .rowsInserted(IndexSet(integer: rows.count - 1)) + indexByID[row.id] = newIndex + return .rowsInserted(IndexSet(integer: newIndex)) } @discardableResult @@ -90,6 +91,9 @@ struct TableRows: Sendable { let normalized = Self.normalize(values: values, toCount: columns.count) let row = Row(id: .inserted(UUID()), values: normalized) rows.insert(row, at: index) + for offset in index.. Delta { guard !pageRows.isEmpty else { return .none } let firstIndex = rows.count + rows.reserveCapacity(rows.count + pageRows.count) + indexByID.reserveCapacity(indexByID.count + pageRows.count) for (idx, values) in pageRows.enumerated() { let normalized = Self.normalize(values: values, toCount: columns.count) - rows.append(Row(id: .existing(offset + idx), values: normalized)) + let row = Row(id: .existing(offset + idx), values: normalized) + let newIndex = firstIndex + idx + rows.append(row) + indexByID[row.id] = newIndex } - let lastIndex = rows.count - 1 - return .rowsInserted(IndexSet(integersIn: firstIndex...lastIndex)) + return .rowsInserted(IndexSet(integersIn: firstIndex...(rows.count - 1))) } @discardableResult mutating func remove(rowIDs: Set) -> Delta { guard !rowIDs.isEmpty else { return .none } var indices = IndexSet() - for (index, row) in rows.enumerated() where rowIDs.contains(row.id) { - indices.insert(index) + for id in rowIDs { + if let i = indexByID[id] { + indices.insert(i) + } } return removeIndices(indices) } @@ -125,11 +135,16 @@ struct TableRows: Sendable { mutating func replace(rows replacementRows: [[String?]], offset: Int = 0) -> Delta { var rebuilt = ContiguousArray() rebuilt.reserveCapacity(replacementRows.count) + var rebuiltIndex = [RowID: Int]() + rebuiltIndex.reserveCapacity(replacementRows.count) for (idx, values) in replacementRows.enumerated() { let normalized = Self.normalize(values: values, toCount: columns.count) - rebuilt.append(Row(id: .existing(offset + idx), values: normalized)) + let row = Row(id: .existing(offset + idx), values: normalized) + rebuilt.append(row) + rebuiltIndex[row.id] = idx } rows = rebuilt + indexByID = rebuiltIndex return .fullReplace } @@ -194,14 +209,39 @@ struct TableRows: Sendable { private mutating func removeIndices(_ indices: IndexSet) -> Delta { guard !indices.isEmpty else { return .none } for index in indices.reversed() { + let removedID = rows[index].id rows.remove(at: index) + indexByID.removeValue(forKey: removedID) + } + if let minRemoved = indices.min(), minRemoved < rows.count { + for offset in minRemoved.. [String?] { - if values.count == targetCount { return values } - if values.count > targetCount { return Array(values.prefix(targetCount)) } - return values + Array(repeating: nil, count: targetCount - values.count) + private static func normalize(values: [String?], toCount targetCount: Int) -> ContiguousArray { + if values.count == targetCount { + return ContiguousArray(values) + } + var result = ContiguousArray() + result.reserveCapacity(targetCount) + if values.count > targetCount { + result.append(contentsOf: values.prefix(targetCount)) + } else { + result.append(contentsOf: values) + result.append(contentsOf: ContiguousArray(repeating: nil, count: targetCount - values.count)) + } + return result + } + + private static func buildIndex(for rows: ContiguousArray) -> [RowID: Int] { + var index = [RowID: Int]() + index.reserveCapacity(rows.count) + for (i, row) in rows.enumerated() { + index[row.id] = i + } + return index } } diff --git a/TablePro/Theme/MaterialAccessibility.swift b/TablePro/Theme/MaterialAccessibility.swift new file mode 100644 index 000000000..036c58a19 --- /dev/null +++ b/TablePro/Theme/MaterialAccessibility.swift @@ -0,0 +1,78 @@ +import SwiftUI + +internal enum MaterialRole { + case banner + case sidebar + case toolbar + case inlineControl + case scrim + + var solidFallback: Color { + switch self { + case .banner, .toolbar, .inlineControl: + Color(nsColor: .controlBackgroundColor) + case .sidebar: + Color(nsColor: .windowBackgroundColor) + case .scrim: + Color(nsColor: .windowBackgroundColor).opacity(0.95) + } + } +} + +private struct AccessibleMaterialBackground: ViewModifier { + let role: MaterialRole + let material: Material + + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency + @Environment(\.colorSchemeContrast) private var contrast + + func body(content: Content) -> some View { + if reduceTransparency || contrast == .increased { + content.background(role.solidFallback) + } else { + content.background(material) + } + } +} + +private struct AccessibleMaterialBackgroundShape: ViewModifier { + let role: MaterialRole + let material: Material + let shape: S + + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency + @Environment(\.colorSchemeContrast) private var contrast + + func body(content: Content) -> some View { + if reduceTransparency || contrast == .increased { + content.background(role.solidFallback, in: shape) + } else { + content.background(material, in: shape) + } + } +} + +internal struct AccessibleMaterialScrim: View { + let material: Material + + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency + @Environment(\.colorSchemeContrast) private var contrast + + var body: some View { + if reduceTransparency || contrast == .increased { + Rectangle().fill(MaterialRole.scrim.solidFallback) + } else { + Rectangle().fill(material) + } + } +} + +internal extension View { + func themeMaterial(_ role: MaterialRole, _ material: Material) -> some View { + modifier(AccessibleMaterialBackground(role: role, material: material)) + } + + func themeMaterial(_ role: MaterialRole, _ material: Material, in shape: S) -> some View { + modifier(AccessibleMaterialBackgroundShape(role: role, material: material, shape: shape)) + } +} diff --git a/TablePro/ViewModels/ConnectionDataCache.swift b/TablePro/ViewModels/ConnectionDataCache.swift index 34989874c..631da242b 100644 --- a/TablePro/ViewModels/ConnectionDataCache.swift +++ b/TablePro/ViewModels/ConnectionDataCache.swift @@ -10,12 +10,16 @@ import Observation @MainActor @Observable internal final class ConnectionDataCache { - private static var instances: [UUID: ConnectionDataCache] = [:] + private static let instances = NSMapTable( + keyOptions: .strongMemory, + valueOptions: .weakMemory + ) static func shared(for connectionId: UUID) -> ConnectionDataCache { - if let existing = instances[connectionId] { return existing } + let key = connectionId as NSUUID + if let existing = instances.object(forKey: key) { return existing } let cache = ConnectionDataCache(connectionId: connectionId) - instances[connectionId] = cache + instances.setObject(cache, forKey: key) return cache } diff --git a/TablePro/Views/Components/ProFeatureGate.swift b/TablePro/Views/Components/ProFeatureGate.swift index cc9058969..e3830edb1 100644 --- a/TablePro/Views/Components/ProFeatureGate.swift +++ b/TablePro/Views/Components/ProFeatureGate.swift @@ -35,8 +35,7 @@ struct ProFeatureGateModifier: ViewModifier { let access = licenseManager.checkFeature(feature) ZStack { - Rectangle() - .fill(.ultraThinMaterial) + AccessibleMaterialScrim(material: .ultraThinMaterial) VStack(spacing: 12) { Image(systemName: feature.systemImage) diff --git a/TablePro/Views/Connection/WelcomeWindowView.swift b/TablePro/Views/Connection/WelcomeWindowView.swift index 3ba55aa81..0967ede23 100644 --- a/TablePro/Views/Connection/WelcomeWindowView.swift +++ b/TablePro/Views/Connection/WelcomeWindowView.swift @@ -206,7 +206,7 @@ struct WelcomeWindowView: View { onImportFromFile: { vm.importConnectionsFromFile() } ) .frame(width: 240) - .background(.regularMaterial) + .themeMaterial(.sidebar, .regularMaterial) Divider() diff --git a/TablePro/Views/ERDiagram/ERDiagramToolbar.swift b/TablePro/Views/ERDiagram/ERDiagramToolbar.swift index 53f40c764..ca8dc113c 100644 --- a/TablePro/Views/ERDiagram/ERDiagramToolbar.swift +++ b/TablePro/Views/ERDiagram/ERDiagramToolbar.swift @@ -72,7 +72,7 @@ struct ERDiagramToolbar: View { } .padding(.horizontal, 12) .padding(.vertical, 6) - .background(.thinMaterial, in: Capsule()) + .themeMaterial(.toolbar, .thinMaterial, in: Capsule()) .overlay(Capsule().strokeBorder(.quaternary, lineWidth: 0.5)) .padding(12) } diff --git a/TablePro/Views/Editor/QuerySuccessView.swift b/TablePro/Views/Editor/QuerySuccessView.swift deleted file mode 100644 index 3fcca5cc1..000000000 --- a/TablePro/Views/Editor/QuerySuccessView.swift +++ /dev/null @@ -1,78 +0,0 @@ -// -// QuerySuccessView.swift -// TablePro -// -// Success message view for non-SELECT queries (INSERT, UPDATE, DELETE, etc.) -// - -import SwiftUI - -/// Displays success message for queries that don't return result sets -struct QuerySuccessView: View { - let rowsAffected: Int - let executionTime: TimeInterval? - let statusMessage: String? - - var body: some View { - VStack(spacing: 16) { - Spacer() - - // Success icon - Image(systemName: "checkmark.circle.fill") - .font(.largeTitle) - .imageScale(.large) - .symbolRenderingMode(.hierarchical) - .foregroundStyle(Color(nsColor: .systemGreen)) - - // Success message - Text("Query executed successfully") - .font(.headline) - .foregroundStyle(.primary) - - // Details - HStack(spacing: 8) { - // Rows affected - Label("\(rowsAffected) row\(rowsAffected == 1 ? "" : "s") affected", systemImage: "square.stack.3d.up") - .foregroundStyle(.secondary) - - if let time = executionTime { - Text("•") - .foregroundStyle(.tertiary) - - // Execution time - Text(formatExecutionTime(time)) - .foregroundStyle(.secondary) - } - } - .font(.subheadline) - - if let statusMessage { - Text(statusMessage) - .foregroundStyle(.secondary) - .font(.caption) - } - - Spacer() - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(nsColor: .textBackgroundColor)) - } - - private func formatExecutionTime(_ time: TimeInterval) -> String { - if time < 0.001 { - let ms = String(format: "%.3f", time * 1_000) - return String(format: String(localized: "%@ ms"), ms) - } else if time < 1 { - let ms = String(format: "%.2f", time * 1_000) - return String(format: String(localized: "%@ ms"), ms) - } else { - let secs = String(format: "%.2f", time) - return String(format: String(localized: "%@ s"), secs) - } - } -} - -#Preview { - QuerySuccessView(rowsAffected: 3, executionTime: 0.025, statusMessage: "Processed: 1.5 GB | Billed: 1.5 GB | ~$0.01") - .frame(width: 400, height: 300) -} diff --git a/TablePro/Views/Main/Child/DataTabGridDelegate.swift b/TablePro/Views/Main/Child/DataTabGridDelegate.swift index 4ac906cc1..be372ac87 100644 --- a/TablePro/Views/Main/Child/DataTabGridDelegate.swift +++ b/TablePro/Views/Main/Child/DataTabGridDelegate.swift @@ -109,7 +109,7 @@ final class DataTabGridDelegate: DataGridViewDelegate { return menu } - weak var tableViewCoordinator: (any TableViewCoordinating)? + weak var tableViewCoordinator: TableViewCoordinator? func dataGridAttach(tableViewCoordinator: TableViewCoordinator) { self.tableViewCoordinator = tableViewCoordinator diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 65b972bf5..35e366088 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -615,8 +615,8 @@ struct MainEditorContentView: View { var detected: [ValueDisplayFormat?] = Array(repeating: nil, count: columns.count) if smartDetectionEnabled { let sampleRows: [[String?]]? = { - let rows = tableRows?.rows.prefix(10).map(\.values) ?? [] - return rows.isEmpty ? nil : Array(rows) + let rows: [[String?]] = tableRows?.rows.prefix(10).map { Array($0.values) } ?? [] + return rows.isEmpty ? nil : rows }() detected = ValueDisplayDetector.detect( columns: columns, diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift index 368250ceb..2de583fb3 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift @@ -8,7 +8,17 @@ import Foundation extension MainContentCoordinator { - /// Handle selection from the quick switcher palette + func showQuickSwitcher() { + quickSwitcherPanel.show( + schemaProvider: SchemaProviderRegistry.shared.getOrCreate(for: connection.id), + connectionId: connection.id, + databaseType: connection.type, + onSelect: { [weak self] item in + self?.handleQuickSwitcherSelection(item) + } + ) + } + func handleQuickSwitcherSelection(_ item: QuickSwitcherItem) { switch item.kind { case .table, .systemTable: diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift index bdbb136c9..2afd84100 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift @@ -173,7 +173,7 @@ extension MainContentCoordinator { 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 + return Array(tableRows.rows[idx].values) } guard !rows.isEmpty else { return } let converter = JsonRowConverter( diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift index ec136c88b..f0c0a60f7 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift @@ -24,7 +24,7 @@ extension MainContentCoordinator { guard !editedFields.isEmpty else { return } let tableRows = tabSessionRegistry.tableRows(for: tab.id) - let changes: [RowChange] = selectionState.indices.sorted().compactMap { rowIndex in + let changes: [RowChange] = selectionState.indices.sorted().compactMap { rowIndex -> RowChange? in guard rowIndex < tableRows.rows.count else { return nil } let originalRow = tableRows.rows[rowIndex].values return RowChange( @@ -39,7 +39,7 @@ extension MainContentCoordinator { newValue: field.newValue ) }, - originalRow: originalRow + originalRow: Array(originalRow) ) } diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index 3a28aae16..90e17514a 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -171,7 +171,7 @@ extension MainContentView { var allRows: [[String?]] = [] for index in selectedIndices.sorted() { if index < tableRows.rows.count { - allRows.append(tableRows.rows[index].values) + allRows.append(Array(tableRows.rows[index].values)) } } @@ -232,7 +232,7 @@ extension MainContentView { for rowIndex in capturedEditState.selectedRowIndices { guard rowIndex < tableRows.rows.count else { continue } - let originalRow = tableRows.rows[rowIndex].values + let originalRow = Array(tableRows.rows[rowIndex].values) let oldValue: String? if columnIndex < capturedEditState.fields.count, diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 3e172b8fd..7b2811037 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -770,7 +770,7 @@ final class MainContentCommandActions { } func openQuickSwitcher() { - coordinator?.activeSheet = .quickSwitcher + coordinator?.showQuickSwitcher() } func openConnectionSwitcher() { diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 6bbf18695..75d5ee4fc 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -45,7 +45,6 @@ enum ActiveSheet: Identifiable { case databaseSwitcher case exportDialog case importDialog - case quickSwitcher case exportQueryResults case maintenance(operation: String, tableName: String) @@ -54,7 +53,6 @@ enum ActiveSheet: Identifiable { case .databaseSwitcher: "databaseSwitcher" case .exportDialog: "exportDialog" case .importDialog: "importDialog" - case .quickSwitcher: "quickSwitcher" case .exportQueryResults: "exportQueryResults" case .maintenance(let operation, let tableName): "maintenance-\(operation)-\(tableName)" } @@ -86,6 +84,7 @@ final class MainContentCoordinator { let selectionState = GridSelectionState() let tabManager: QueryTabManager let changeManager: DataChangeManager + let quickSwitcherPanel = QuickSwitcherPanelController() let toolbarState: ConnectionToolbarState let tabSessionRegistry: TabSessionRegistry let queryExecutor: QueryExecutor @@ -1213,7 +1212,7 @@ final class MainContentCoordinator { let sortColumns = currentSort.columns let colTypes = tableRows.columnTypes let storageRows = tableRows.rows - let snapshotRows: [(id: RowID, values: [String?])] = storageRows.map { ($0.id, $0.values) } + let snapshotRows: [(id: RowID, values: [String?])] = storageRows.map { ($0.id, Array($0.values)) } if storageRows.count > 1_000 { activeSortTasks[tabId]?.cancel() diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 1959a7ae9..ed9b9c8d8 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -198,16 +198,6 @@ struct MainContentView: View { connection: connection, initialFileURL: coordinator.importFileURL ) - case .quickSwitcher: - QuickSwitcherSheet( - isPresented: dismissBinding, - schemaProvider: SchemaProviderRegistry.shared.getOrCreate(for: connection.id), - connectionId: connection.id, - databaseType: connection.type, - onSelect: { item in - coordinator.handleQuickSwitcherSelection(item) - } - ) case .maintenance(let operation, let tableName): MaintenanceSheet( operation: operation, diff --git a/TablePro/Views/QuickSwitcher/QuickSwitcherPanelController.swift b/TablePro/Views/QuickSwitcher/QuickSwitcherPanelController.swift new file mode 100644 index 000000000..c081d61a5 --- /dev/null +++ b/TablePro/Views/QuickSwitcher/QuickSwitcherPanelController.swift @@ -0,0 +1,83 @@ +// +// QuickSwitcherPanelController.swift +// TablePro +// + +import AppKit +import SwiftUI + +@MainActor +final class QuickSwitcherPanelController { + private var panel: NSPanel? + private var resignKeyObserver: NSObjectProtocol? + + func show( + schemaProvider: SQLSchemaProvider, + connectionId: UUID, + databaseType: DatabaseType, + onSelect: @escaping (QuickSwitcherItem) -> Void + ) { + if let panel, panel.isVisible { + panel.makeKeyAndOrderFront(nil) + return + } + + let dismissAction: () -> Void = { [weak self] in + self?.dismiss() + } + + let content = QuickSwitcherContentView( + schemaProvider: schemaProvider, + connectionId: connectionId, + databaseType: databaseType, + onSelect: { [weak self] item in + onSelect(item) + self?.dismiss() + }, + onDismiss: dismissAction + ) + + let host = NSHostingController(rootView: content) + host.preferredContentSize = NSSize(width: 460, height: 480) + + let panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 460, height: 480), + styleMask: [.titled, .nonactivatingPanel, .fullSizeContentView, .closable], + backing: .buffered, + defer: false + ) + panel.title = String(localized: "Quick Switcher") + panel.titleVisibility = .hidden + panel.titlebarAppearsTransparent = true + panel.isMovableByWindowBackground = true + panel.hidesOnDeactivate = true + panel.becomesKeyOnlyIfNeeded = false + panel.level = .floating + panel.isReleasedWhenClosed = false + panel.contentViewController = host + panel.center() + + self.panel = panel + + resignKeyObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didResignKeyNotification, + object: panel, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.dismiss() + } + } + + panel.makeKeyAndOrderFront(nil) + } + + func dismiss() { + if let observer = resignKeyObserver { + NotificationCenter.default.removeObserver(observer) + resignKeyObserver = nil + } + panel?.orderOut(nil) + panel = nil + } +} diff --git a/TablePro/Views/QuickSwitcher/QuickSwitcherView.swift b/TablePro/Views/QuickSwitcher/QuickSwitcherView.swift index c3ac582f2..7ccf78bf0 100644 --- a/TablePro/Views/QuickSwitcher/QuickSwitcherView.swift +++ b/TablePro/Views/QuickSwitcher/QuickSwitcherView.swift @@ -2,23 +2,18 @@ // QuickSwitcherView.swift // TablePro // -// Quick switcher sheet for searching and opening database objects. -// Presented as a native SwiftUI .sheet() via the ActiveSheet pattern. +// SwiftUI content for the quick switcher. Hosted inside an NSPanel +// by `QuickSwitcherPanelController` for the Spotlight presentation pattern. // import SwiftUI -// MARK: - Sheet - -/// Native SwiftUI sheet for the quick switcher, matching the project's ActiveSheet pattern. -internal struct QuickSwitcherSheet: View { - @Binding var isPresented: Bool - @Environment(\.dismiss) private var dismiss - +internal struct QuickSwitcherContentView: View { let schemaProvider: SQLSchemaProvider let connectionId: UUID let databaseType: DatabaseType let onSelect: (QuickSwitcherItem) -> Void + let onDismiss: () -> Void @State private var viewModel = QuickSwitcherViewModel() @@ -30,19 +25,16 @@ internal struct QuickSwitcherSheet: View { var body: some View { VStack(spacing: 0) { - // Header Text("Quick Switcher") .font(.body.weight(.semibold)) .padding(.vertical, 12) Divider() - // Search toolbar searchToolbar Divider() - // Content if viewModel.isLoading { loadingView } else if viewModel.filteredItems.isEmpty { @@ -53,7 +45,6 @@ internal struct QuickSwitcherSheet: View { Divider() - // Footer footer } .frame(width: 460, height: 480) @@ -65,7 +56,7 @@ internal struct QuickSwitcherSheet: View { databaseType: databaseType ) } - .onExitCommand { dismiss() } + .onExitCommand { onDismiss() } .onKeyPress(.return) { openSelectedItem() return .handled @@ -102,7 +93,6 @@ internal struct QuickSwitcherSheet: View { ScrollViewReader { proxy in List(selection: $viewModel.selectedItemId) { if viewModel.searchText.isEmpty { - // Grouped by kind when not searching ForEach(viewModel.groupedItems, id: \.kind) { group in Section { ForEach(group.items) { item in @@ -115,7 +105,6 @@ internal struct QuickSwitcherSheet: View { } } } else { - // Flat ranked list when searching ForEach(viewModel.filteredItems) { item in itemRow(item) } @@ -181,7 +170,7 @@ internal struct QuickSwitcherSheet: View { VStack(spacing: 12) { ProgressView() .scaleEffect(0.8) - Text("Loading...") + Text(String(localized: "Loading...")) .font(.callout) .foregroundStyle(.secondary) } @@ -195,13 +184,13 @@ internal struct QuickSwitcherSheet: View { .foregroundStyle(.secondary) if viewModel.searchText.isEmpty { - Text("No objects found") + Text(String(localized: "No objects found")) .font(.body.weight(.medium)) } else { - Text("No matching objects") + Text(String(localized: "No matching objects")) .font(.body.weight(.medium)) - Text("No objects match \"\(viewModel.searchText)\"") + Text(String(format: String(localized: "No objects match \"%@\""), viewModel.searchText)) .font(.subheadline) .foregroundStyle(.secondary) } @@ -213,13 +202,13 @@ internal struct QuickSwitcherSheet: View { private var footer: some View { HStack { - Button("Cancel") { - dismiss() + Button(String(localized: "Cancel")) { + onDismiss() } Spacer() - Button("Open") { + Button(String(localized: "Open")) { openSelectedItem() } .buttonStyle(.borderedProminent) @@ -232,18 +221,18 @@ internal struct QuickSwitcherSheet: View { private func sectionTitle(for kind: QuickSwitcherItemKind) -> String { switch kind { - case .table: return "TABLES" - case .view: return "VIEWS" - case .systemTable: return "SYSTEM TABLES" - case .database: return "DATABASES" - case .schema: return "SCHEMAS" - case .queryHistory: return "RECENT QUERIES" + case .table: return String(localized: "TABLES") + case .view: return String(localized: "VIEWS") + case .systemTable: return String(localized: "SYSTEM TABLES") + case .database: return String(localized: "DATABASES") + case .schema: return String(localized: "SCHEMAS") + case .queryHistory: return String(localized: "RECENT QUERIES") } } private func openSelectedItem() { guard let item = viewModel.selectedItem else { return } onSelect(item) - dismiss() + onDismiss() } } diff --git a/TablePro/Views/Results/CellTextField.swift b/TablePro/Views/Results/CellTextField.swift index 71792d3e0..4e3d55915 100644 --- a/TablePro/Views/Results/CellTextField.swift +++ b/TablePro/Views/Results/CellTextField.swift @@ -43,7 +43,7 @@ final class CellTextField: NSTextField { var view: NSView? = self while let parent = view?.superview { - if let rowView = parent as? TableRowViewWithMenu { + if let rowView = parent as? DataGridRowView { if let menu = rowView.menu(for: event) { NSMenu.popUpContextMenu(menu, with: event, for: self) } @@ -58,7 +58,7 @@ final class CellTextField: NSTextField { var view: NSView? = self while let parent = view?.superview { - if let rowView = parent as? TableRowViewWithMenu { + if let rowView = parent as? DataGridRowView { return rowView.menu(for: event) } view = parent diff --git a/TablePro/Views/Results/Cells/AccessoryButtons.swift b/TablePro/Views/Results/Cells/AccessoryButtons.swift deleted file mode 100644 index e40341cc7..000000000 --- a/TablePro/Views/Results/Cells/AccessoryButtons.swift +++ /dev/null @@ -1,55 +0,0 @@ -// -// AccessoryButtons.swift -// TablePro -// - -import AppKit - -@MainActor -final class FKArrowButton: NSButton { - var fkRow: Int = -1 - var fkColumnIndex: Int = -1 -} - -@MainActor -final class CellChevronButton: NSButton { - var cellRow: Int = -1 - var cellColumnIndex: Int = -1 -} - -@MainActor -enum AccessoryButtonFactory { - static func makeFKArrowButton() -> FKArrowButton { - let button = FKArrowButton() - button.bezelStyle = .inline - button.isBordered = false - button.image = NSImage( - systemSymbolName: "arrow.right.circle.fill", - accessibilityDescription: String(localized: "Navigate to referenced row") - ) - button.contentTintColor = .tertiaryLabelColor - button.translatesAutoresizingMaskIntoConstraints = false - button.setContentHuggingPriority(.required, for: .horizontal) - button.setContentCompressionResistancePriority(.required, for: .horizontal) - button.imageScaling = .scaleProportionallyDown - button.isHidden = true - return button - } - - static func makeChevronButton() -> CellChevronButton { - let chevron = CellChevronButton() - chevron.bezelStyle = .inline - chevron.isBordered = false - chevron.image = NSImage( - systemSymbolName: "chevron.up.chevron.down", - accessibilityDescription: String(localized: "Open editor") - ) - chevron.contentTintColor = .tertiaryLabelColor - chevron.translatesAutoresizingMaskIntoConstraints = false - chevron.setContentHuggingPriority(.required, for: .horizontal) - chevron.setContentCompressionResistancePriority(.required, for: .horizontal) - chevron.imageScaling = .scaleProportionallyDown - chevron.isHidden = true - return chevron - } -} diff --git a/TablePro/Views/Results/Cells/CellFocusOverlay.swift b/TablePro/Views/Results/Cells/CellFocusOverlay.swift deleted file mode 100644 index ed8ecda9f..000000000 --- a/TablePro/Views/Results/Cells/CellFocusOverlay.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// CellFocusOverlay.swift -// TablePro -// - -import AppKit - -final class CellFocusOverlay: NSView { - enum Style { - case hidden - case contrastingBorder - } - - var style: Style = .hidden { - didSet { - guard oldValue != style else { return } - applyStyle() - } - } - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - wantsLayer = true - translatesAutoresizingMaskIntoConstraints = false - isHidden = true - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func hitTest(_ point: NSPoint) -> NSView? { nil } - - override func viewDidChangeEffectiveAppearance() { - super.viewDidChangeEffectiveAppearance() - if !isHidden { applyStyle() } - } - - private func applyStyle() { - switch style { - case .hidden: - isHidden = true - layer?.borderWidth = 0 - case .contrastingBorder: - isHidden = false - layer?.borderWidth = 2 - layer?.borderColor = NSColor.alternateSelectedControlTextColor.cgColor - } - } -} diff --git a/TablePro/Views/Results/Cells/DataGridBaseCellView.swift b/TablePro/Views/Results/Cells/DataGridBaseCellView.swift deleted file mode 100644 index 7b6b82452..000000000 --- a/TablePro/Views/Results/Cells/DataGridBaseCellView.swift +++ /dev/null @@ -1,226 +0,0 @@ -// -// DataGridBaseCellView.swift -// TablePro -// - -import AppKit -import QuartzCore - -class DataGridBaseCellView: NSTableCellView { - class var reuseIdentifier: NSUserInterfaceItemIdentifier { - fatalError("subclass must override reuseIdentifier") - } - - let cellTextField: CellTextField - weak var accessoryDelegate: DataGridCellAccessoryDelegate? - var nullDisplayString: String = "" - var cellRow: Int = -1 - var cellColumnIndex: Int = -1 - - private var textFieldTrailingConstraint: NSLayoutConstraint! - - var changeBackgroundColor: NSColor? { - didSet { - if let color = changeBackgroundColor { - backgroundView.layer?.backgroundColor = color.cgColor - backgroundView.isHidden = (backgroundStyle == .emphasized) - } else { - backgroundView.layer?.backgroundColor = nil - backgroundView.isHidden = true - } - } - } - - var isFocusedCell: Bool = false { - didSet { - guard oldValue != isFocusedCell else { return } - updateFocusPresentation() - } - } - - private lazy var focusOverlay: CellFocusOverlay = { - let overlay = CellFocusOverlay() - addSubview(overlay) - NSLayoutConstraint.activate([ - overlay.leadingAnchor.constraint(equalTo: leadingAnchor), - overlay.trailingAnchor.constraint(equalTo: trailingAnchor), - overlay.topAnchor.constraint(equalTo: topAnchor), - overlay.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - return overlay - }() - - private(set) lazy var backgroundView: NSView = { - let view = NSView() - view.wantsLayer = true - view.translatesAutoresizingMaskIntoConstraints = false - addSubview(view, positioned: .below, relativeTo: subviews.first) - NSLayoutConstraint.activate([ - view.leadingAnchor.constraint(equalTo: leadingAnchor), - view.trailingAnchor.constraint(equalTo: trailingAnchor), - view.topAnchor.constraint(equalTo: topAnchor), - view.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - view.isHidden = true - return view - }() - - override required init(frame frameRect: NSRect) { - cellTextField = Self.makeTextField() - super.init(frame: frameRect) - commonInit() - } - - required init?(coder: NSCoder) { - cellTextField = Self.makeTextField() - super.init(coder: coder) - commonInit() - } - - private static func makeTextField() -> CellTextField { - let field = CellTextField() - field.font = ThemeEngine.shared.dataGridFonts.regular - field.drawsBackground = false - field.isBordered = false - field.focusRingType = .none - field.lineBreakMode = .byTruncatingTail - field.maximumNumberOfLines = 1 - field.cell?.truncatesLastVisibleLine = true - field.cell?.usesSingleLineMode = true - field.translatesAutoresizingMaskIntoConstraints = false - return field - } - - private func commonInit() { - wantsLayer = true - textField = cellTextField - addSubview(cellTextField) - - textFieldTrailingConstraint = cellTextField.trailingAnchor.constraint( - equalTo: trailingAnchor, - constant: -DataGridMetrics.cellHorizontalInset - ) - - NSLayoutConstraint.activate([ - cellTextField.leadingAnchor.constraint(equalTo: leadingAnchor, constant: DataGridMetrics.cellHorizontalInset), - textFieldTrailingConstraint, - cellTextField.centerYAnchor.constraint(equalTo: centerYAnchor), - ]) - - installAccessory() - } - - func configure(content: DataGridCellContent, state: DataGridCellState) { - cellRow = state.row - cellColumnIndex = state.columnIndex - - applyContent(content, isLargeDataset: state.isLargeDataset) - applyVisualState(state) - - cellTextField.isEditable = state.isEditable && !state.visualState.isDeleted - - let newInset = textFieldTrailingInset(for: content, state: state) - if textFieldTrailingConstraint.constant != newInset { - textFieldTrailingConstraint.constant = newInset - } - - updateAccessoryVisibility(content: content, state: state) - - cellTextField.setAccessibilityLabel(content.accessibilityLabel) - setAccessibilityRowIndexRange(NSRange(location: state.row, length: 1)) - setAccessibilityColumnIndexRange(NSRange(location: state.columnIndex, length: 1)) - } - - func installAccessory() {} - - func updateAccessoryVisibility(content: DataGridCellContent, state: DataGridCellState) {} - - func textFieldTrailingInset(for content: DataGridCellContent, state: DataGridCellState) -> CGFloat { - -4 - } - - private func applyContent(_ content: DataGridCellContent, isLargeDataset: Bool) { - cellTextField.placeholderString = nil - - switch content.placeholder { - case .none: - cellTextField.stringValue = content.displayText - cellTextField.originalValue = content.rawValue - cellTextField.font = ThemeEngine.shared.dataGridFonts.regular - cellTextField.tag = DataGridFontVariant.regular - cellTextField.textColor = .labelColor - - case .null: - cellTextField.stringValue = "" - cellTextField.originalValue = nil - cellTextField.font = ThemeEngine.shared.dataGridFonts.italic - cellTextField.tag = DataGridFontVariant.italic - cellTextField.textColor = .secondaryLabelColor - if !isLargeDataset { - cellTextField.placeholderString = nullDisplayString - } - - case .empty: - cellTextField.stringValue = "" - cellTextField.originalValue = nil - cellTextField.font = ThemeEngine.shared.dataGridFonts.italic - cellTextField.tag = DataGridFontVariant.italic - cellTextField.textColor = .secondaryLabelColor - if !isLargeDataset { - cellTextField.placeholderString = String(localized: "Empty") - } - - case .defaultMarker: - cellTextField.stringValue = "" - cellTextField.originalValue = nil - cellTextField.font = ThemeEngine.shared.dataGridFonts.medium - cellTextField.tag = DataGridFontVariant.medium - cellTextField.textColor = .systemBlue - if !isLargeDataset { - cellTextField.placeholderString = String(localized: "DEFAULT") - } - } - } - - private func applyVisualState(_ state: DataGridCellState) { - CATransaction.begin() - CATransaction.setDisableActions(true) - - if state.visualState.isDeleted { - changeBackgroundColor = ThemeEngine.shared.colors.dataGrid.deleted - } else if state.visualState.isInserted { - changeBackgroundColor = ThemeEngine.shared.colors.dataGrid.inserted - } else if state.visualState.modifiedColumns.contains(state.columnIndex) { - changeBackgroundColor = ThemeEngine.shared.colors.dataGrid.modified - } else { - changeBackgroundColor = nil - } - - isFocusedCell = state.isFocused - - CATransaction.commit() - } - - override var backgroundStyle: NSView.BackgroundStyle { - didSet { - backgroundView.isHidden = (backgroundStyle == .emphasized) || (changeBackgroundColor == nil) - updateFocusPresentation() - } - } - - override var focusRingMaskBounds: NSRect { - backgroundStyle == .emphasized ? .zero : bounds - } - - override func drawFocusRingMask() { - guard backgroundStyle != .emphasized else { return } - NSBezierPath(rect: bounds).fill() - } - - private func updateFocusPresentation() { - let onEmphasized = backgroundStyle == .emphasized - focusOverlay.style = (isFocusedCell && onEmphasized) ? .contrastingBorder : .hidden - focusRingType = (isFocusedCell && !onEmphasized) ? .exterior : .none - noteFocusRingMaskChanged() - } -} diff --git a/TablePro/Views/Results/Cells/DataGridBlobCellView.swift b/TablePro/Views/Results/Cells/DataGridBlobCellView.swift deleted file mode 100644 index d4f93bf43..000000000 --- a/TablePro/Views/Results/Cells/DataGridBlobCellView.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// DataGridBlobCellView.swift -// TablePro -// - -import AppKit - -final class DataGridBlobCellView: DataGridChevronCellView { - override class var reuseIdentifier: NSUserInterfaceItemIdentifier { - NSUserInterfaceItemIdentifier("dataCell.blob") - } -} diff --git a/TablePro/Views/Results/Cells/DataGridBooleanCellView.swift b/TablePro/Views/Results/Cells/DataGridBooleanCellView.swift deleted file mode 100644 index 5bbe227ea..000000000 --- a/TablePro/Views/Results/Cells/DataGridBooleanCellView.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// DataGridBooleanCellView.swift -// TablePro -// - -import AppKit - -final class DataGridBooleanCellView: DataGridChevronCellView { - override class var reuseIdentifier: NSUserInterfaceItemIdentifier { - NSUserInterfaceItemIdentifier("dataCell.boolean") - } -} diff --git a/TablePro/Views/Results/Cells/DataGridCellPalette.swift b/TablePro/Views/Results/Cells/DataGridCellPalette.swift new file mode 100644 index 000000000..3be1018be --- /dev/null +++ b/TablePro/Views/Results/Cells/DataGridCellPalette.swift @@ -0,0 +1,35 @@ +// +// DataGridCellPalette.swift +// TablePro +// + +import AppKit + +@MainActor +struct DataGridCellPalette: Equatable { + let regularFont: NSFont + let italicFont: NSFont + let mediumFont: NSFont + let deletedRowText: NSColor + let modifiedColumnTint: NSColor + + static let placeholder = DataGridCellPalette( + regularFont: .systemFont(ofSize: NSFont.systemFontSize), + italicFont: .systemFont(ofSize: NSFont.systemFontSize), + mediumFont: .systemFont(ofSize: NSFont.systemFontSize, weight: .medium), + deletedRowText: .secondaryLabelColor, + modifiedColumnTint: .systemYellow + ) +} + +extension ThemeEngine { + var dataGridCellPalette: DataGridCellPalette { + DataGridCellPalette( + regularFont: dataGridFonts.regular, + italicFont: dataGridFonts.italic, + mediumFont: dataGridFonts.medium, + deletedRowText: colors.dataGrid.deletedText, + modifiedColumnTint: colors.dataGrid.modified + ) + } +} diff --git a/TablePro/Views/Results/Cells/DataGridCellRegistry.swift b/TablePro/Views/Results/Cells/DataGridCellRegistry.swift index c167d52ff..3ee1dab9e 100644 --- a/TablePro/Views/Results/Cells/DataGridCellRegistry.swift +++ b/TablePro/Views/Results/Cells/DataGridCellRegistry.swift @@ -13,17 +13,25 @@ final class DataGridCellRegistry { weak var textFieldDelegate: NSTextFieldDelegate? private(set) var nullDisplayString: String + private(set) var palette: DataGridCellPalette private var settingsCancellable: AnyCancellable? + private var themeCancellable: AnyCancellable? private let rowNumberCellIdentifier = NSUserInterfaceItemIdentifier("RowNumberCellView") init() { nullDisplayString = AppSettingsManager.shared.dataGrid.nullDisplay + palette = ThemeEngine.shared.dataGridCellPalette settingsCancellable = AppEvents.shared.dataGridSettingsChanged .receive(on: RunLoop.main) .sink { [weak self] _ in self?.nullDisplayString = AppSettingsManager.shared.dataGrid.nullDisplay } + themeCancellable = AppEvents.shared.themeChanged + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.palette = ThemeEngine.shared.dataGridCellPalette + } } func resolveKind( @@ -43,41 +51,17 @@ final class DataGridCellRegistry { return .text } - func dequeueCell(of kind: DataGridCellKind, in tableView: NSTableView) -> DataGridBaseCellView { - let identifier: NSUserInterfaceItemIdentifier - let cellType: DataGridBaseCellView.Type - - switch kind { - case .text: - identifier = DataGridTextCellView.reuseIdentifier - cellType = DataGridTextCellView.self - case .foreignKey: - identifier = DataGridForeignKeyCellView.reuseIdentifier - cellType = DataGridForeignKeyCellView.self - case .dropdown: - identifier = DataGridDropdownCellView.reuseIdentifier - cellType = DataGridDropdownCellView.self - case .boolean: - identifier = DataGridBooleanCellView.reuseIdentifier - cellType = DataGridBooleanCellView.self - case .date: - identifier = DataGridDateCellView.reuseIdentifier - cellType = DataGridDateCellView.self - case .json: - identifier = DataGridJsonCellView.reuseIdentifier - cellType = DataGridJsonCellView.self - case .blob: - identifier = DataGridBlobCellView.reuseIdentifier - cellType = DataGridBlobCellView.self - } - - if let reused = tableView.makeView(withIdentifier: identifier, owner: nil) as? DataGridBaseCellView { + func dequeueCell(in tableView: NSTableView) -> DataGridCellView { + if let reused = tableView.makeView( + withIdentifier: DataGridCellView.reuseIdentifier, + owner: nil + ) as? DataGridCellView { reused.nullDisplayString = nullDisplayString return reused } - let cell = cellType.init(frame: .zero) - cell.identifier = identifier + let cell = DataGridCellView(frame: .zero) + cell.identifier = DataGridCellView.reuseIdentifier cell.accessoryDelegate = accessoryDelegate cell.cellTextField.delegate = textFieldDelegate cell.nullDisplayString = nullDisplayString diff --git a/TablePro/Views/Results/Cells/DataGridCellView.swift b/TablePro/Views/Results/Cells/DataGridCellView.swift new file mode 100644 index 000000000..a332d3353 --- /dev/null +++ b/TablePro/Views/Results/Cells/DataGridCellView.swift @@ -0,0 +1,369 @@ +// +// DataGridCellView.swift +// TablePro +// + +import AppKit + +@MainActor +final class DataGridCellView: NSTableCellView { + static let reuseIdentifier = NSUserInterfaceItemIdentifier("dataCell") + + let cellTextField: CellTextField + weak var accessoryDelegate: DataGridCellAccessoryDelegate? + var nullDisplayString: String = "" + + var kind: DataGridCellKind = .text + private(set) var cellRow: Int = -1 + private(set) var cellColumnIndex: Int = -1 + + private var modifiedColumnTint: NSColor? + private var deletedRowTextColor: NSColor? + private var accessoryVisible: Bool = false + private var isFocusedCell: Bool = false + private var onEmphasizedSelection: Bool = false + + private var textFieldTrailingConstraint: NSLayoutConstraint! + private var accessoryWidthConstraint: NSLayoutConstraint! + private var accessoryHeightConstraint: NSLayoutConstraint! + + private static let fkSymbol = makeSymbol( + name: "arrow.right.circle.fill", + accessibilityDescription: String(localized: "Navigate to referenced row") + ) + private static let chevronSymbol = makeSymbol( + name: "chevron.up.chevron.down", + accessibilityDescription: String(localized: "Open editor") + ) + + private lazy var accessoryButton: NSButton = { + let button = NSButton() + button.bezelStyle = .inline + button.isBordered = false + button.imageScaling = .scaleProportionallyDown + button.translatesAutoresizingMaskIntoConstraints = false + button.target = self + button.action = #selector(handleAccessoryClick(_:)) + button.isHidden = true + button.setContentHuggingPriority(.required, for: .horizontal) + button.setContentCompressionResistancePriority(.required, for: .horizontal) + addSubview(button) + + accessoryWidthConstraint = button.widthAnchor.constraint(equalToConstant: 0) + accessoryHeightConstraint = button.heightAnchor.constraint(equalToConstant: 0) + NSLayoutConstraint.activate([ + button.trailingAnchor.constraint( + equalTo: trailingAnchor, + constant: -DataGridMetrics.cellHorizontalInset + ), + button.centerYAnchor.constraint(equalTo: centerYAnchor), + accessoryWidthConstraint, + accessoryHeightConstraint, + ]) + return button + }() + + override init(frame frameRect: NSRect) { + cellTextField = Self.makeTextField() + super.init(frame: frameRect) + commonInit() + } + + required init?(coder: NSCoder) { + cellTextField = Self.makeTextField() + super.init(coder: coder) + commonInit() + } + + private static func makeTextField() -> CellTextField { + let field = CellTextField() + field.font = ThemeEngine.shared.dataGridFonts.regular + field.drawsBackground = false + field.isBordered = false + field.focusRingType = .none + field.lineBreakMode = .byTruncatingTail + field.maximumNumberOfLines = 1 + field.cell?.truncatesLastVisibleLine = true + field.cell?.usesSingleLineMode = true + field.translatesAutoresizingMaskIntoConstraints = false + return field + } + + private static func makeSymbol(name: String, accessibilityDescription: String) -> NSImage { + guard let image = NSImage(systemSymbolName: name, accessibilityDescription: accessibilityDescription) else { + return NSImage() + } + image.isTemplate = true + return image + } + + private func commonInit() { + wantsLayer = true + layerContentsRedrawPolicy = .onSetNeedsDisplay + canDrawSubviewsIntoLayer = true + + addSubview(cellTextField) + textFieldTrailingConstraint = cellTextField.trailingAnchor.constraint( + equalTo: trailingAnchor, + constant: -DataGridMetrics.cellHorizontalInset + ) + NSLayoutConstraint.activate([ + cellTextField.leadingAnchor.constraint( + equalTo: leadingAnchor, + constant: DataGridMetrics.cellHorizontalInset + ), + textFieldTrailingConstraint, + cellTextField.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + + setAccessibilityElement(true) + setAccessibilityRole(.cell) + } + + override var allowsVibrancy: Bool { false } + + override func makeBackingLayer() -> CALayer { + let layer = super.makeBackingLayer() + layer.actions = Self.disabledLayerActions + return layer + } + + private static let disabledLayerActions: [String: any CAAction] = [ + "position": NSNull(), + "bounds": NSNull(), + "frame": NSNull(), + "contents": NSNull(), + "hidden": NSNull(), + ] + + func configure( + kind: DataGridCellKind, + content: DataGridCellContent, + state: DataGridCellState, + palette: DataGridCellPalette + ) { + self.kind = kind + cellRow = state.row + cellColumnIndex = state.columnIndex + + applyContent(content, isLargeDataset: state.isLargeDataset, visualState: state.visualState, palette: palette) + applyVisualState(state, palette: palette) + + cellTextField.isEditable = state.isEditable && !state.visualState.isDeleted + + let newAccessoryVisible = computeAccessoryVisibility(content: content, state: state) + let newInset = trailingInset(for: newAccessoryVisible) + if textFieldTrailingConstraint.constant != newInset { + textFieldTrailingConstraint.constant = newInset + } + if newAccessoryVisible != accessoryVisible { + accessoryVisible = newAccessoryVisible + } + configureAccessoryButton() + + cellTextField.setAccessibilityLabel(content.accessibilityLabel) + setAccessibilityRowIndexRange(NSRange(location: state.row, length: 1)) + setAccessibilityColumnIndexRange(NSRange(location: state.columnIndex, length: 1)) + } + + private func applyContent( + _ content: DataGridCellContent, + isLargeDataset: Bool, + visualState: RowVisualState, + palette: DataGridCellPalette + ) { + cellTextField.placeholderString = nil + deletedRowTextColor = visualState.isDeleted ? palette.deletedRowText : nil + + switch content.placeholder { + case .none: + cellTextField.stringValue = content.displayText + cellTextField.originalValue = content.rawValue + cellTextField.font = palette.regularFont + cellTextField.tag = DataGridFontVariant.regular + cellTextField.textColor = deletedRowTextColor ?? .labelColor + + case .null: + cellTextField.stringValue = "" + cellTextField.originalValue = nil + cellTextField.font = palette.italicFont + cellTextField.tag = DataGridFontVariant.italic + cellTextField.textColor = deletedRowTextColor ?? .secondaryLabelColor + if !isLargeDataset { + cellTextField.placeholderString = nullDisplayString + } + + case .empty: + cellTextField.stringValue = "" + cellTextField.originalValue = nil + cellTextField.font = palette.italicFont + cellTextField.tag = DataGridFontVariant.italic + cellTextField.textColor = deletedRowTextColor ?? .secondaryLabelColor + if !isLargeDataset { + cellTextField.placeholderString = String(localized: "Empty") + } + + case .defaultMarker: + cellTextField.stringValue = "" + cellTextField.originalValue = nil + cellTextField.font = palette.mediumFont + cellTextField.tag = DataGridFontVariant.medium + cellTextField.textColor = deletedRowTextColor ?? .systemBlue + if !isLargeDataset { + cellTextField.placeholderString = String(localized: "DEFAULT") + } + } + } + + private func applyVisualState(_ state: DataGridCellState, palette: DataGridCellPalette) { + let nextTint: NSColor? + if state.visualState.isDeleted || state.visualState.isInserted { + nextTint = nil + } else if state.visualState.modifiedColumns.contains(state.columnIndex) { + nextTint = palette.modifiedColumnTint + } else { + nextTint = nil + } + + if !colorsEqual(modifiedColumnTint, nextTint) { + modifiedColumnTint = nextTint + needsDisplay = true + } + + if isFocusedCell != state.isFocused { + isFocusedCell = state.isFocused + updateFocusPresentation() + } + } + + override var backgroundStyle: NSView.BackgroundStyle { + didSet { + let nextEmphasized = backgroundStyle == .emphasized + guard nextEmphasized != onEmphasizedSelection else { return } + onEmphasizedSelection = nextEmphasized + needsDisplay = true + updateFocusPresentation() + updateAccessoryTint() + } + } + + private func updateFocusPresentation() { + focusRingType = (isFocusedCell && !onEmphasizedSelection) ? .exterior : .none + noteFocusRingMaskChanged() + needsDisplay = true + } + + override var focusRingMaskBounds: NSRect { + onEmphasizedSelection ? .zero : bounds + } + + override func drawFocusRingMask() { + guard !onEmphasizedSelection else { return } + NSBezierPath(rect: bounds).fill() + } + + override func draw(_ dirtyRect: NSRect) { + if let tint = modifiedColumnTint, !onEmphasizedSelection { + tint.setFill() + bounds.fill() + } + drawFocusBorderIfNeeded() + } + + override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(newSize) + needsDisplay = true + } + + private func drawFocusBorderIfNeeded() { + guard isFocusedCell, onEmphasizedSelection else { return } + let path = NSBezierPath(rect: bounds.insetBy(dx: 1, dy: 1)) + path.lineWidth = 2 + NSColor.alternateSelectedControlTextColor.setStroke() + path.stroke() + } + + private func configureAccessoryButton() { + guard accessoryVisible else { + if !accessoryButton.isHidden { + accessoryButton.isHidden = true + } + return + } + let (image, size, label) = accessoryAssets() + accessoryButton.image = image + accessoryButton.setAccessibilityLabel(label) + accessoryWidthConstraint.constant = size.width + accessoryHeightConstraint.constant = size.height + accessoryButton.isHidden = false + updateAccessoryTint() + } + + private func accessoryAssets() -> (NSImage, NSSize, String) { + switch kind { + case .foreignKey: + return ( + Self.fkSymbol, + NSSize(width: 16, height: 16), + String(localized: "Navigate to referenced row") + ) + case .text: + return (NSImage(), .zero, "") + case .dropdown, .boolean, .date, .json, .blob: + return ( + Self.chevronSymbol, + NSSize(width: 12, height: 14), + String(localized: "Open editor") + ) + } + } + + private func updateAccessoryTint() { + accessoryButton.contentTintColor = onEmphasizedSelection + ? .alternateSelectedControlTextColor + : .secondaryLabelColor + } + + private func trailingInset(for accessoryVisible: Bool) -> CGFloat { + guard accessoryVisible else { return -DataGridMetrics.cellHorizontalInset } + switch kind { + case .foreignKey: return -22 + case .text: return -DataGridMetrics.cellHorizontalInset + case .dropdown, .boolean, .date, .json, .blob: return -18 + } + } + + private func computeAccessoryVisibility( + content: DataGridCellContent, + state: DataGridCellState + ) -> Bool { + switch kind { + case .foreignKey: + guard let raw = content.rawValue, !raw.isEmpty else { return false } + return true + case .text: + return false + case .dropdown, .boolean, .date, .json, .blob: + return state.isEditable && !state.visualState.isDeleted + } + } + + @objc private func handleAccessoryClick(_ sender: NSButton) { + switch kind { + case .foreignKey: + accessoryDelegate?.dataGridCellDidClickFKArrow(row: cellRow, columnIndex: cellColumnIndex) + case .text: + return + case .dropdown, .boolean, .date, .json, .blob: + accessoryDelegate?.dataGridCellDidClickChevron(row: cellRow, columnIndex: cellColumnIndex) + } + } + + private func colorsEqual(_ lhs: NSColor?, _ rhs: NSColor?) -> Bool { + switch (lhs, rhs) { + case (nil, nil): return true + case let (l?, r?): return l == r + default: return false + } + } +} diff --git a/TablePro/Views/Results/Cells/DataGridChevronCellView.swift b/TablePro/Views/Results/Cells/DataGridChevronCellView.swift deleted file mode 100644 index cc1787b0d..000000000 --- a/TablePro/Views/Results/Cells/DataGridChevronCellView.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// DataGridChevronCellView.swift -// TablePro -// - -import AppKit - -class DataGridChevronCellView: DataGridBaseCellView { - private lazy var chevronButton: CellChevronButton = AccessoryButtonFactory.makeChevronButton() - - override func installAccessory() { - addSubview(chevronButton) - NSLayoutConstraint.activate([ - chevronButton.trailingAnchor.constraint( - equalTo: trailingAnchor, - constant: -DataGridMetrics.cellHorizontalInset - ), - chevronButton.centerYAnchor.constraint(equalTo: centerYAnchor), - chevronButton.widthAnchor.constraint(equalToConstant: 10), - chevronButton.heightAnchor.constraint(equalToConstant: 12), - ]) - chevronButton.target = self - chevronButton.action = #selector(handleChevronClick(_:)) - } - - override func updateAccessoryVisibility(content: DataGridCellContent, state: DataGridCellState) { - let show = state.isEditable && !state.visualState.isDeleted - chevronButton.isHidden = !show - if show { - chevronButton.cellRow = state.row - chevronButton.cellColumnIndex = state.columnIndex - } else { - chevronButton.cellRow = -1 - chevronButton.cellColumnIndex = -1 - } - } - - override func textFieldTrailingInset(for content: DataGridCellContent, state: DataGridCellState) -> CGFloat { - let show = state.isEditable && !state.visualState.isDeleted - return show ? -18 : -DataGridMetrics.cellHorizontalInset - } - - @objc - private func handleChevronClick(_ sender: CellChevronButton) { - accessoryDelegate?.dataGridCellDidClickChevron(row: sender.cellRow, columnIndex: sender.cellColumnIndex) - } -} diff --git a/TablePro/Views/Results/Cells/DataGridDateCellView.swift b/TablePro/Views/Results/Cells/DataGridDateCellView.swift deleted file mode 100644 index 252b9c8db..000000000 --- a/TablePro/Views/Results/Cells/DataGridDateCellView.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// DataGridDateCellView.swift -// TablePro -// - -import AppKit - -final class DataGridDateCellView: DataGridChevronCellView { - override class var reuseIdentifier: NSUserInterfaceItemIdentifier { - NSUserInterfaceItemIdentifier("dataCell.date") - } -} diff --git a/TablePro/Views/Results/Cells/DataGridDropdownCellView.swift b/TablePro/Views/Results/Cells/DataGridDropdownCellView.swift deleted file mode 100644 index 3af7116a3..000000000 --- a/TablePro/Views/Results/Cells/DataGridDropdownCellView.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// DataGridDropdownCellView.swift -// TablePro -// - -import AppKit - -final class DataGridDropdownCellView: DataGridChevronCellView { - override class var reuseIdentifier: NSUserInterfaceItemIdentifier { - NSUserInterfaceItemIdentifier("dataCell.dropdown") - } -} diff --git a/TablePro/Views/Results/Cells/DataGridForeignKeyCellView.swift b/TablePro/Views/Results/Cells/DataGridForeignKeyCellView.swift deleted file mode 100644 index 602ab1241..000000000 --- a/TablePro/Views/Results/Cells/DataGridForeignKeyCellView.swift +++ /dev/null @@ -1,55 +0,0 @@ -// -// DataGridForeignKeyCellView.swift -// TablePro -// - -import AppKit - -final class DataGridForeignKeyCellView: DataGridBaseCellView { - override class var reuseIdentifier: NSUserInterfaceItemIdentifier { - NSUserInterfaceItemIdentifier("dataCell.foreignKey") - } - - private lazy var fkButton: FKArrowButton = AccessoryButtonFactory.makeFKArrowButton() - - override func installAccessory() { - addSubview(fkButton) - NSLayoutConstraint.activate([ - fkButton.trailingAnchor.constraint( - equalTo: trailingAnchor, - constant: -DataGridMetrics.cellHorizontalInset - ), - fkButton.centerYAnchor.constraint(equalTo: centerYAnchor), - fkButton.widthAnchor.constraint(equalToConstant: 16), - fkButton.heightAnchor.constraint(equalToConstant: 16), - ]) - fkButton.target = self - fkButton.action = #selector(handleFKClick(_:)) - } - - override func updateAccessoryVisibility(content: DataGridCellContent, state: DataGridCellState) { - let show = isAccessoryVisible(for: content) - fkButton.isHidden = !show - if show { - fkButton.fkRow = state.row - fkButton.fkColumnIndex = state.columnIndex - } else { - fkButton.fkRow = -1 - fkButton.fkColumnIndex = -1 - } - } - - override func textFieldTrailingInset(for content: DataGridCellContent, state: DataGridCellState) -> CGFloat { - isAccessoryVisible(for: content) ? -22 : -4 - } - - private func isAccessoryVisible(for content: DataGridCellContent) -> Bool { - guard let raw = content.rawValue else { return false } - return !raw.isEmpty - } - - @objc - private func handleFKClick(_ sender: FKArrowButton) { - accessoryDelegate?.dataGridCellDidClickFKArrow(row: sender.fkRow, columnIndex: sender.fkColumnIndex) - } -} diff --git a/TablePro/Views/Results/Cells/DataGridJsonCellView.swift b/TablePro/Views/Results/Cells/DataGridJsonCellView.swift deleted file mode 100644 index 4873a7f46..000000000 --- a/TablePro/Views/Results/Cells/DataGridJsonCellView.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// DataGridJsonCellView.swift -// TablePro -// - -import AppKit - -final class DataGridJsonCellView: DataGridChevronCellView { - override class var reuseIdentifier: NSUserInterfaceItemIdentifier { - NSUserInterfaceItemIdentifier("dataCell.json") - } -} diff --git a/TablePro/Views/Results/Cells/DataGridTextCellView.swift b/TablePro/Views/Results/Cells/DataGridTextCellView.swift deleted file mode 100644 index a5850bf7a..000000000 --- a/TablePro/Views/Results/Cells/DataGridTextCellView.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// DataGridTextCellView.swift -// TablePro -// - -import AppKit - -final class DataGridTextCellView: DataGridBaseCellView { - override class var reuseIdentifier: NSUserInterfaceItemIdentifier { - NSUserInterfaceItemIdentifier("dataCell.text") - } -} diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 5dc2fe8c4..15180ce6a 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -14,7 +14,13 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData var isEditable: Bool var sortedIDs: [RowID]? private(set) var columnDisplayFormats: [ValueDisplayFormat?] = [] - private var displayCache: [RowID: [String?]] = [:] + private let displayCache: NSCache = { + let cache = NSCache() + cache.countLimit = 5_000 + cache.totalCostLimit = 32 * 1024 * 1024 + cache.name = "TablePro.DataGrid.displayCache" + return cache + }() weak var delegate: (any DataGridViewDelegate)? weak var activeFKPreviewPopover: NSPopover? var dropdownColumns: Set? @@ -109,8 +115,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData var layoutPersistTask: Task? static let rowViewIdentifier = NSUserInterfaceItemIdentifier("TableRowView") - private var rowVisualStateCache: [Int: RowVisualState] = [:] - private var lastVisualStateCacheVersion: Int = 0 + let visualIndex = RowVisualIndex() private let largeDatasetThreshold = 5_000 var isLargeDataset: Bool { cachedRowCount > largeDatasetThreshold } @@ -195,8 +200,14 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData private func releaseData() { overlayEditor?.dismiss(commit: false) - rowVisualStateCache.removeAll() - displayCache.removeAll() + settingsCancellable?.cancel() + settingsCancellable = nil + themeCancellable?.cancel() + themeCancellable = nil + teardownCancellable?.cancel() + teardownCancellable = nil + visualIndex.clear() + displayCache.removeAllObjects() columnDisplayFormats = [] cachedRowCount = 0 cachedColumnCount = 0 @@ -224,14 +235,14 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData func applyInsertedRows(_ indices: IndexSet) { guard let tableView else { return } - rebuildVisualStateCache() + visualIndex.rebuild(from: changeManager, sortedIDs: sortedIDs) updateCache() tableView.insertRows(at: indices, withAnimation: .slideDown) } func applyRemovedRows(_ indices: IndexSet) { guard let tableView else { return } - rebuildVisualStateCache() + visualIndex.rebuild(from: changeManager, sortedIDs: sortedIDs) updateCache() tableView.removeRows(at: indices, withAnimation: .slideUp) } @@ -264,42 +275,54 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData } func displayValue(forID id: RowID, column: Int, rawValue: String?, columnType: ColumnType?) -> String? { - if let cachedRow = displayCache[id], column >= 0, column < cachedRow.count, let cached = cachedRow[column] { + let key = RowIDKey(id) + if let box = displayCache.object(forKey: key), + column >= 0, column < box.values.count, + let cached = box.values[column] { return cached } let format = column >= 0 && column < columnDisplayFormats.count ? columnDisplayFormats[column] : nil let formatted = CellDisplayFormatter.format(rawValue, columnType: columnType, displayFormat: format) ?? rawValue - var rowCache = displayCache[id] ?? [] - let neededCount = max(column + 1, columnDisplayFormats.count) - if rowCache.count < neededCount { - rowCache.append(contentsOf: Array(repeating: nil, count: neededCount - rowCache.count)) + let neededCount = max(column + 1, columnDisplayFormats.count, cachedColumnCount) + let box: RowDisplayBox + if let existing = displayCache.object(forKey: key) { + box = existing + if box.values.count < neededCount { + box.values.reserveCapacity(neededCount) + for _ in box.values.count..() + values.reserveCapacity(neededCount) + for _ in 0..= 0, column < rowCache.count { - rowCache[column] = formatted + if column >= 0, column < box.values.count { + box.values[column] = formatted } - displayCache[id] = rowCache + displayCache.setObject(box, forKey: key, cost: displayCacheCost(box.values)) return formatted } func invalidateDisplayCache() { - displayCache.removeAll() + displayCache.removeAllObjects() } func invalidateAllDisplayCaches() { - displayCache.removeAll() - rebuildVisualStateCache() + displayCache.removeAllObjects() + visualIndex.rebuild(from: changeManager, sortedIDs: sortedIDs) } func updateDisplayFormats(_ formats: [ValueDisplayFormat?]) { columnDisplayFormats = formats - displayCache.removeAll() + displayCache.removeAllObjects() } func syncDisplayFormats(_ formats: [ValueDisplayFormat?]) { guard formats != columnDisplayFormats else { return } columnDisplayFormats = formats - displayCache.removeAll() + displayCache.removeAllObjects() } func preWarmDisplayCache(upTo rowCount: Int) { @@ -307,41 +330,42 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData let displayCount = sortedIDs?.count ?? tableRows.count let count = min(rowCount, displayCount) guard count > 0 else { return } + let columnCount = tableRows.columns.count for displayIndex in 0..() + values.reserveCapacity(columnCount) + for _ in 0..() - aliveIDs.reserveCapacity(tableRows.count) - for row in tableRows.rows { - aliveIDs.insert(row.id) + private func displayCacheCost(_ values: ContiguousArray) -> Int { + var total = 0 + for value in values { + if let s = value { total &+= s.utf8.count } } - displayCache = displayCache.filter { aliveIDs.contains($0.key) } + return total } private func invalidateDisplayCache(forDisplayRow displayIndex: Int, column: Int) { guard let row = displayRow(at: displayIndex) else { return } - guard var rowCache = displayCache[row.id], column >= 0, column < rowCache.count else { return } - rowCache[column] = nil - displayCache[row.id] = rowCache + let key = RowIDKey(row.id) + guard let box = displayCache.object(forKey: key), column >= 0, column < box.values.count else { return } + box.values[column] = nil + displayCache.setObject(box, forKey: key, cost: displayCacheCost(box.values)) } func applyDelta(_ delta: Delta) { @@ -352,7 +376,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData else { return } guard row >= 0, row < tableView.numberOfRows else { return } invalidateDisplayCache(forDisplayRow: row, column: column) - rebuildVisualStateCache() + visualIndex.updateRow(row, from: changeManager, sortedIDs: sortedIDs) tableView.reloadData( forRowIndexes: IndexSet(integer: row), columnIndexes: IndexSet(integer: tableColumn) @@ -375,7 +399,9 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData invalidateDisplayCache(forDisplayRow: position.row, column: position.column) } guard !rowSet.isEmpty, !colSet.isEmpty else { return } - rebuildVisualStateCache() + for row in rowSet { + visualIndex.updateRow(row, from: changeManager, sortedIDs: sortedIDs) + } tableView.reloadData(forRowIndexes: rowSet, columnIndexes: colSet) case .rowsInserted(let indices): guard !indices.isEmpty else { return } @@ -384,7 +410,6 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData case .rowsRemoved(let indices): guard !indices.isEmpty else { return } removeMissingIDsFromSortedIDs() - pruneDisplayCacheToAliveIDs() applyRemovedRows(indices) case .columnsReplaced, .fullReplace: sortedIDs = nil @@ -421,6 +446,22 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData forRowIndexes: IndexSet(integersIn: visibleRange.location..<(visibleRange.location + visibleRange.length)), columnIndexes: IndexSet(integersIn: 0.. - if let sorted = sortedIDs { - insertedRowIndices = Set() - for (displayIndex, id) in sorted.enumerated() where id.isInserted { - insertedRowIndices.insert(displayIndex) - } - } else { - insertedRowIndices = changeManager.insertedRowIndices - } - - if !changeManager.hasChanges && insertedRowIndices.isEmpty { - return - } - - for rowChange in changeManager.rowChanges { - let rowIndex = rowChange.rowIndex - let isDeleted = rowChange.type == .delete - let isInserted = insertedRowIndices.contains(rowIndex) || rowChange.type == .insert - let modifiedColumns: Set = rowChange.type == .update - ? Set(rowChange.cellChanges.map { $0.columnIndex }) - : [] - - rowVisualStateCache[rowIndex] = RowVisualState( - isDeleted: isDeleted, - isInserted: isInserted, - modifiedColumns: modifiedColumns - ) - } - - for rowIndex in insertedRowIndices where rowVisualStateCache[rowIndex] == nil { - rowVisualStateCache[rowIndex] = RowVisualState( - isDeleted: false, - isInserted: true, - modifiedColumns: [] - ) - } - } + // MARK: - Row Visual State func visualState(for row: Int) -> RowVisualState { if let delegateState = delegate?.dataGridVisualState(forRow: row) { return delegateState } - return rowVisualStateCache[row] ?? .empty + return visualIndex.visualState(for: row) } // MARK: - NSTableViewDataSource diff --git a/TablePro/Views/Results/DataGridRowView.swift b/TablePro/Views/Results/DataGridRowView.swift new file mode 100644 index 000000000..03de645f6 --- /dev/null +++ b/TablePro/Views/Results/DataGridRowView.swift @@ -0,0 +1,356 @@ +// +// DataGridRowView.swift +// TablePro +// + +import AppKit + +@MainActor +final class DataGridRowView: NSTableRowView { + weak var coordinator: TableViewCoordinator? + var rowIndex: Int = 0 + + private var rowTint: NSColor? { + didSet { + guard !colorsEqual(oldValue, rowTint) else { return } + needsDisplay = true + } + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layerContentsRedrawPolicy = .onSetNeedsDisplay + canDrawSubviewsIntoLayer = true + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func makeBackingLayer() -> CALayer { + let layer = super.makeBackingLayer() + layer.actions = Self.disabledLayerActions + return layer + } + + private static let disabledLayerActions: [String: any CAAction] = [ + "position": NSNull(), + "bounds": NSNull(), + "frame": NSNull(), + "contents": NSNull(), + "hidden": NSNull(), + ] + + func applyVisualState(_ state: RowVisualState) { + if state.isDeleted { + rowTint = ThemeEngine.shared.colors.dataGrid.deleted + } else if state.isInserted { + rowTint = ThemeEngine.shared.colors.dataGrid.inserted + } else { + rowTint = nil + } + } + + override func drawBackground(in dirtyRect: NSRect) { + super.drawBackground(in: dirtyRect) + guard let rowTint, !isSelected else { return } + rowTint.setFill() + bounds.fill() + } + + private func colorsEqual(_ lhs: NSColor?, _ rhs: NSColor?) -> Bool { + switch (lhs, rhs) { + case (nil, nil): return true + case let (l?, r?): return l == r + default: return false + } + } + + override func menu(for event: NSEvent) -> NSMenu? { + guard let coordinator = coordinator, + let tableView = coordinator.tableView else { return nil } + + let locationInRow = convert(event.locationInWindow, from: nil) + let locationInTable = tableView.convert(locationInRow, from: self) + let clickedColumn = tableView.column(at: locationInTable) + + let dataColumnIndex: Int = clickedColumn > 0 + ? DataGridView.dataColumnIndex(for: clickedColumn, in: tableView, schema: coordinator.identitySchema) ?? -1 + : -1 + + let menu = NSMenu() + + if coordinator.changeManager.isRowDeleted(rowIndex) { + menu.addItem( + withTitle: String(localized: "Undo Delete"), action: #selector(undoDeleteRow), keyEquivalent: "" + ).target = self + return menu + } + + let copyItem = NSMenuItem( + title: String(localized: "Copy"), action: #selector(copySelectedOrCurrentRow), keyEquivalent: "") + copyItem.target = self + menu.addItem(copyItem) + + let copyAsMenu = NSMenu() + + if dataColumnIndex >= 0 { + let copyCellItem = NSMenuItem( + title: String(localized: "Cell Value"), action: #selector(copyCellValue(_:)), + keyEquivalent: "") + copyCellItem.representedObject = dataColumnIndex + copyCellItem.target = self + copyAsMenu.addItem(copyCellItem) + } + + let copyWithHeadersItem = NSMenuItem( + title: String(localized: "With Headers"), + action: #selector(copySelectedOrCurrentRowWithHeaders), + keyEquivalent: "") + copyWithHeadersItem.target = self + copyAsMenu.addItem(copyWithHeadersItem) + + let jsonItem = NSMenuItem( + title: String(localized: "JSON"), + action: #selector(copyAsJson), + keyEquivalent: "") + jsonItem.target = self + copyAsMenu.addItem(jsonItem) + + if let dbType = coordinator.databaseType, + dbType != .mongodb && dbType != .redis, + coordinator.tableName != nil { + copyAsMenu.addItem(NSMenuItem.separator()) + + let insertItem = NSMenuItem( + title: String(localized: "INSERT Statement(s)"), + action: #selector(copyAsInsert), + keyEquivalent: "") + insertItem.target = self + copyAsMenu.addItem(insertItem) + + let updateItem = NSMenuItem( + title: String(localized: "UPDATE Statement(s)"), + action: #selector(copyAsUpdate), + keyEquivalent: "") + updateItem.target = self + copyAsMenu.addItem(updateItem) + } + + let copyAsItem = NSMenuItem(title: String(localized: "Copy as"), action: nil, keyEquivalent: "") + copyAsItem.submenu = copyAsMenu + menu.addItem(copyAsItem) + + if coordinator.isEditable { + let pasteItem = NSMenuItem( + title: String(localized: "Paste"), action: #selector(pasteRows), keyEquivalent: "") + pasteItem.target = self + menu.addItem(pasteItem) + } + + let tableRows = coordinator.tableRowsProvider() + if dataColumnIndex >= 0, dataColumnIndex < tableRows.columns.count { + let columnName = tableRows.columns[dataColumnIndex] + if let fkInfo = tableRows.columnForeignKeys[columnName], + let cellValue = coordinator.cellValue(at: rowIndex, column: dataColumnIndex), + !cellValue.isEmpty { + menu.addItem(NSMenuItem.separator()) + + let previewItem = NSMenuItem( + title: String(localized: "Preview Referenced Row"), + action: #selector(previewForeignKey(_:)), + keyEquivalent: "" + ) + previewItem.representedObject = dataColumnIndex + previewItem.target = self + menu.addItem(previewItem) + + let navItem = NSMenuItem( + title: String(format: String(localized: "Open %@"), fkInfo.referencedTable), + action: #selector(navigateToForeignKey(_:)), + keyEquivalent: "" + ) + navItem.representedObject = dataColumnIndex + navItem.target = self + menu.addItem(navItem) + } + } + + if coordinator.isEditable { + menu.addItem(NSMenuItem.separator()) + } + + if coordinator.isEditable && dataColumnIndex >= 0 { + let setValueMenu = NSMenu() + + let emptyItem = NSMenuItem( + title: String(localized: "Empty"), action: #selector(setEmptyValue(_:)), keyEquivalent: "") + emptyItem.representedObject = dataColumnIndex + emptyItem.target = self + setValueMenu.addItem(emptyItem) + + let columnName = dataColumnIndex < tableRows.columns.count + ? tableRows.columns[dataColumnIndex] + : nil + + let isNullable = columnName.flatMap { tableRows.columnNullable[$0] } ?? true + if isNullable { + let nullItem = NSMenuItem( + title: String(localized: "NULL"), action: #selector(setNullValue(_:)), keyEquivalent: "") + nullItem.representedObject = dataColumnIndex + nullItem.target = self + setValueMenu.addItem(nullItem) + } + + let hasDefault = columnName.flatMap({ tableRows.columnDefaults[$0] ?? nil }) != nil + if hasDefault { + let defaultItem = NSMenuItem( + title: String(localized: "Default"), action: #selector(setDefaultValue(_:)), keyEquivalent: "") + defaultItem.representedObject = dataColumnIndex + defaultItem.target = self + setValueMenu.addItem(defaultItem) + } + + let setValueItem = NSMenuItem(title: String(localized: "Set Value"), action: nil, keyEquivalent: "") + setValueItem.submenu = setValueMenu + menu.addItem(setValueItem) + } + + menu.addItem(NSMenuItem.separator()) + + let exportItem = NSMenuItem( + title: String(localized: "Export Results..."), + action: #selector(exportResults), + keyEquivalent: "" + ) + exportItem.target = self + menu.addItem(exportItem) + + if coordinator.isEditable { + let duplicateItem = NSMenuItem( + title: String(localized: "Duplicate"), action: #selector(duplicateRow), keyEquivalent: "") + duplicateItem.target = self + menu.addItem(duplicateItem) + + let deleteItem = NSMenuItem( + title: String(localized: "Delete"), + action: #selector(deleteRow), + keyEquivalent: "" + ) + deleteItem.target = self + menu.addItem(deleteItem) + } + + return menu + } + + @objc private func deleteRow() { + let indices: Set = if let selected = coordinator?.selectedRowIndices, !selected.isEmpty { + selected + } else { + [rowIndex] + } + coordinator?.delegate?.dataGridDeleteRows(indices) + } + + @objc private func duplicateRow() { + coordinator?.delegate?.dataGridDuplicateRow() + } + + @objc private func undoDeleteRow() { + coordinator?.undoDeleteRow(at: rowIndex) + } + + @objc private func copySelectedOrCurrentRowWithHeaders() { + guard let coordinator = coordinator else { return } + let indices: Set = !coordinator.selectedRowIndices.isEmpty + ? coordinator.selectedRowIndices + : [rowIndex] + coordinator.copyRowsWithHeaders(at: indices) + } + + @objc private func copySelectedOrCurrentRow() { + guard let coordinator = coordinator else { return } + let indices: Set = !coordinator.selectedRowIndices.isEmpty + ? coordinator.selectedRowIndices + : [rowIndex] + coordinator.delegate?.dataGridCopyRows(indices) + } + + @objc private func pasteRows() { + coordinator?.delegate?.dataGridPasteRows() + } + + @objc private func copyCellValue(_ sender: NSMenuItem) { + guard let columnIndex = sender.representedObject as? Int else { return } + coordinator?.copyCellValue(at: rowIndex, columnIndex: columnIndex) + } + + @objc private func setNullValue(_ sender: NSMenuItem) { + guard let columnIndex = sender.representedObject as? Int else { return } + coordinator?.setCellValueAtColumn(nil, at: rowIndex, columnIndex: columnIndex) + } + + @objc private func setEmptyValue(_ sender: NSMenuItem) { + guard let columnIndex = sender.representedObject as? Int else { return } + coordinator?.setCellValueAtColumn("", at: rowIndex, columnIndex: columnIndex) + } + + @objc private func setDefaultValue(_ sender: NSMenuItem) { + guard let columnIndex = sender.representedObject as? Int else { return } + coordinator?.setCellValueAtColumn("__DEFAULT__", at: rowIndex, columnIndex: columnIndex) + } + + @objc private func copyAsInsert() { + guard let coordinator else { return } + let indices: Set = !coordinator.selectedRowIndices.isEmpty + ? coordinator.selectedRowIndices + : [rowIndex] + coordinator.copyRowsAsInsert(at: indices) + } + + @objc private func copyAsUpdate() { + guard let coordinator else { return } + let indices: Set = !coordinator.selectedRowIndices.isEmpty + ? coordinator.selectedRowIndices + : [rowIndex] + coordinator.copyRowsAsUpdate(at: indices) + } + + @objc private func exportResults() { + NotificationCenter.default.post(name: .exportQueryResults, object: nil) + } + + @objc private func copyAsJson() { + guard let coordinator else { return } + let indices: Set = !coordinator.selectedRowIndices.isEmpty + ? coordinator.selectedRowIndices + : [rowIndex] + coordinator.copyRowsAsJson(at: indices) + } + + @objc private func previewForeignKey(_ sender: NSMenuItem) { + guard let columnIndex = sender.representedObject as? Int, + let coordinator, let tableView = coordinator.tableView, + let column = DataGridView.tableColumnIndex( + for: columnIndex, + in: tableView, + schema: coordinator.identitySchema + ) else { return } + coordinator.showForeignKeyPreview( + tableView: tableView, row: rowIndex, column: column, columnIndex: columnIndex + ) + } + + @objc private func navigateToForeignKey(_ sender: NSMenuItem) { + guard let columnIndex = sender.representedObject as? Int, + let coordinator else { return } + let tableRows = coordinator.tableRowsProvider() + guard columnIndex >= 0, columnIndex < tableRows.columns.count else { return } + let columnName = tableRows.columns[columnIndex] + guard let fkInfo = tableRows.columnForeignKeys[columnName], + let value = coordinator.cellValue(at: rowIndex, column: columnIndex) else { return } + coordinator.delegate?.dataGridNavigateFK(value: value, fkInfo: fkInfo) + } +} diff --git a/TablePro/Views/Results/DataGridView+RowActions.swift b/TablePro/Views/Results/DataGridView+RowActions.swift index 36df9f297..a8459765c 100644 --- a/TablePro/Views/Results/DataGridView+RowActions.swift +++ b/TablePro/Views/Results/DataGridView+RowActions.swift @@ -16,9 +16,11 @@ extension TableViewCoordinator { @MainActor func undoDeleteRow(at index: Int) { changeManager.undoRowDeletion(rowIndex: index) + visualIndex.updateRow(index, from: changeManager, sortedIDs: sortedIDs) tableView?.reloadData( forRowIndexes: IndexSet(integer: index), columnIndexes: IndexSet(integersIn: 0..<(tableView?.numberOfColumns ?? 0))) + refreshRowVisualState(at: index) } func addNewRow() { @@ -29,11 +31,11 @@ extension TableViewCoordinator { func undoInsertRow(at index: Int) { delegate?.dataGridUndoInsert(at: index) changeManager.undoRowInsertion(rowIndex: index) + var capturedDelta: Delta = .none tableRowsMutator { rows in - _ = rows.remove(at: IndexSet(integer: index)) + capturedDelta = rows.remove(at: IndexSet(integer: index)) } - updateCache() - tableView?.reloadData() + applyDelta(capturedDelta) } func copyRows(at indices: Set) { @@ -45,7 +47,7 @@ extension TableViewCoordinator { for index in sortedIndices { guard let values = displayRow(at: index)?.values else { continue } - let formatted = formatRowValues(values: values, columnTypes: columnTypes) + let formatted = formatRowValues(values: Array(values), columnTypes: columnTypes) tsvRows.append(formatted.joined(separator: "\t")) htmlRows.append(formatted) } @@ -65,7 +67,7 @@ extension TableViewCoordinator { for index in sortedIndices { guard let values = displayRow(at: index)?.values else { continue } - let formatted = formatRowValues(values: values, columnTypes: columnTypes) + let formatted = formatRowValues(values: Array(values), columnTypes: columnTypes) tsvRows.append(formatted.joined(separator: "\t")) htmlRows.append(formatted) } @@ -75,14 +77,6 @@ extension TableViewCoordinator { ClipboardService.shared.writeRows(tsv: tsv, html: html) } - @MainActor - func setCellValue(_ value: String?, at rowIndex: Int) { - guard let tableView = tableView else { return } - var columnIndex = max(0, tableView.selectedColumn - 1) - if columnIndex < 0 { columnIndex = 0 } - setCellValueAtColumn(value, at: rowIndex, columnIndex: columnIndex) - } - @MainActor func setCellValueAtColumn(_ value: String?, at rowIndex: Int, columnIndex: Int) { commitCellEdit(row: rowIndex, columnIndex: columnIndex, newValue: value) @@ -120,7 +114,7 @@ extension TableViewCoordinator { quoteIdentifier: driver?.quoteIdentifier, escapeStringLiteral: driver?.escapeStringLiteral ) - let rows = indices.sorted().compactMap { displayRow(at: $0)?.values } + let rows: [[String?]] = indices.sorted().compactMap { displayRow(at: $0).map { Array($0.values) } } guard !rows.isEmpty else { return } ClipboardService.shared.writeText(converter.generateInserts(rows: rows)) } catch { @@ -141,7 +135,7 @@ extension TableViewCoordinator { quoteIdentifier: driver?.quoteIdentifier, escapeStringLiteral: driver?.escapeStringLiteral ) - let rows = indices.sorted().compactMap { displayRow(at: $0)?.values } + let rows: [[String?]] = indices.sorted().compactMap { displayRow(at: $0).map { Array($0.values) } } guard !rows.isEmpty else { return } ClipboardService.shared.writeText(converter.generateUpdates(rows: rows)) } catch { @@ -150,7 +144,7 @@ extension TableViewCoordinator { } func copyRowsAsJson(at indices: Set) { - let rows = indices.sorted().compactMap { displayRow(at: $0)?.values } + let rows: [[String?]] = indices.sorted().compactMap { displayRow(at: $0).map { Array($0.values) } } guard !rows.isEmpty else { return } let tableRows = tableRowsProvider() let columnTypes = tableRows.columnTypes @@ -182,7 +176,7 @@ extension TableViewCoordinator { if let values = displayRow(at: row)?.values { let tableRows = tableRowsProvider() - let formatted = formatRowValues(values: values, columnTypes: tableRows.columnTypes) + let formatted = formatRowValues(values: Array(values), columnTypes: tableRows.columnTypes) item.setString(formatted.joined(separator: "\t"), forType: .string) item.setString( HtmlTableEncoder.encode(rows: [formatted], headers: tableRows.columns), diff --git a/TablePro/Views/Results/DataGridView+TypePicker.swift b/TablePro/Views/Results/DataGridView+TypePicker.swift deleted file mode 100644 index 96c917b1b..000000000 --- a/TablePro/Views/Results/DataGridView+TypePicker.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// DataGridView+TypePicker.swift -// TablePro -// -// Extension for database-specific type picker popover in structure view. -// - -import AppKit -import SwiftUI - -extension TableViewCoordinator { - func showTypePickerPopover( - tableView: NSTableView, - row: Int, - column: Int, - columnIndex: Int - ) { - guard tableView.view(atColumn: column, row: row, makeIfNecessary: false) != nil else { return } - - let currentValue = cellValue(at: row, column: columnIndex) ?? "" - let dbType = databaseType ?? .mysql - - let cellRect = tableView.rect(ofRow: row).intersection(tableView.rect(ofColumn: column)) - PopoverPresenter.show( - relativeTo: cellRect, - of: tableView - ) { [weak self] dismiss in - TypePickerContentView( - databaseType: dbType, - currentValue: currentValue, - onCommit: { newValue in - guard let self else { return } - self.commitPopoverEdit(row: row, columnIndex: columnIndex, newValue: newValue) - }, - onDismiss: dismiss - ) - } - } -} diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index e4c360cc2..b2096b918 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -64,6 +64,7 @@ struct DataGridView: NSViewRepresentable { tableView.gridStyleMask = [.solidVerticalGridLineMask] tableView.intercellSpacing = NSSize(width: 1, height: 0) tableView.rowHeight = CGFloat(settings.rowHeight.rawValue) + tableView.usesAutomaticRowHeights = false tableView.delegate = context.coordinator tableView.dataSource = context.coordinator @@ -211,7 +212,7 @@ struct DataGridView: NSViewRepresentable { coordinator.primaryKeyColumns = configuration.primaryKeyColumns coordinator.tabType = configuration.tabType - coordinator.rebuildVisualStateCache() + coordinator.visualIndex.rebuild(from: coordinator.changeManager, sortedIDs: coordinator.sortedIDs) if !latestRows.columns.isEmpty { coordinator.isRebuildingColumns = true diff --git a/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift b/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift index 5972ddcdc..6ba97508c 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift @@ -21,7 +21,7 @@ extension TableViewCoordinator { let storageRow = tableRowsIndex(forDisplayRow: row) let columnName = tableRows.columns[columnIndex] - let originalRow = displayRowValues.values + let originalRow = Array(displayRowValues.values) changeManager.recordCellChange( rowIndex: row, columnIndex: columnIndex, @@ -39,7 +39,7 @@ extension TableViewCoordinator { } delegate?.dataGridDidEditCell(row: row, column: columnIndex, newValue: newValue) invalidateDisplayCache() - rebuildVisualStateCache() + visualIndex.updateRow(row, from: changeManager, sortedIDs: sortedIDs) guard let tableColumnIndex = DataGridView.tableColumnIndex( for: columnIndex, diff --git a/TablePro/Views/Results/Extensions/DataGridView+Click.swift b/TablePro/Views/Results/Extensions/DataGridView+Click.swift index 0231288a1..5e567ef97 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Click.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Click.swift @@ -124,4 +124,34 @@ extension TableViewCoordinator { delegate?.dataGridNavigateFK(value: value, fkInfo: fkInfo) } + + // MARK: - Type Picker Popover + + func showTypePickerPopover( + tableView: NSTableView, + row: Int, + column: Int, + columnIndex: Int + ) { + guard tableView.view(atColumn: column, row: row, makeIfNecessary: false) != nil else { return } + + let currentValue = cellValue(at: row, column: columnIndex) ?? "" + let dbType = databaseType ?? .mysql + + let cellRect = tableView.rect(ofRow: row).intersection(tableView.rect(ofColumn: column)) + PopoverPresenter.show( + relativeTo: cellRect, + of: tableView + ) { [weak self] dismiss in + TypePickerContentView( + databaseType: dbType, + currentValue: currentValue, + onCommit: { newValue in + guard let self else { return } + self.commitPopoverEdit(row: row, columnIndex: columnIndex, newValue: newValue) + }, + onDismiss: dismiss + ) + } + } } diff --git a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift index 836710cff..4d80a4b01 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift @@ -94,8 +94,8 @@ extension TableViewCoordinator { columnIndex: columnIndex ) - let cell = cellRegistry.dequeueCell(of: kind, in: tableView) - cell.configure(content: content, state: cellState) + let cell = cellRegistry.dequeueCell(in: tableView) + cell.configure(kind: kind, content: content, state: cellState, palette: cellRegistry.palette) return cell } @@ -106,15 +106,25 @@ extension TableViewCoordinator { return nil } + func tableView(_ tableView: NSTableView, typeSelectStringFor tableColumn: NSTableColumn?, row: Int) -> String? { + guard let tableColumn else { return nil } + guard tableColumn.identifier != ColumnIdentitySchema.rowNumberIdentifier else { return nil } + guard let columnIndex = dataColumnIndex(from: tableColumn.identifier) else { return nil } + guard let displayRow = displayRow(at: row) else { return nil } + guard columnIndex < displayRow.values.count else { return nil } + return displayRow.values[columnIndex] + } + func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { if let delegateRowView = delegate?.dataGridRowView(for: tableView, row: row, coordinator: self) { return delegateRowView } - let rowView = (tableView.makeView(withIdentifier: Self.rowViewIdentifier, owner: nil) as? TableRowViewWithMenu) - ?? TableRowViewWithMenu() + let rowView = (tableView.makeView(withIdentifier: Self.rowViewIdentifier, owner: nil) as? DataGridRowView) + ?? DataGridRowView() rowView.identifier = Self.rowViewIdentifier rowView.coordinator = self rowView.rowIndex = row + rowView.applyVisualState(visualState(for: row)) return rowView } } diff --git a/TablePro/Views/Results/Extensions/DataGridView+Selection.swift b/TablePro/Views/Results/Extensions/DataGridView+Selection.swift index e7d38f101..749a96ed4 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Selection.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Selection.swift @@ -28,12 +28,11 @@ extension TableViewCoordinator { } func tableViewSelectionDidChange(_ notification: Notification) { - guard !isSyncingSelection else { return } guard let tableView = notification.object as? NSTableView else { return } let previousSelection = selectedRowIndices let newSelection = Set(tableView.selectedRowIndexes.map { $0 }) - if newSelection != previousSelection { + if !isSyncingSelection && newSelection != previousSelection { selectedRowIndices = newSelection } diff --git a/TablePro/Views/Results/HexEditorContentView.swift b/TablePro/Views/Results/HexEditorContentView.swift index e5ef3f49c..828aa28ac 100644 --- a/TablePro/Views/Results/HexEditorContentView.swift +++ b/TablePro/Views/Results/HexEditorContentView.swift @@ -18,7 +18,7 @@ struct HexEditorContentView: View { @State private var isValid: Bool = true @State private var isTruncated: Bool = false @State private var byteCount: Int = 0 - @State private var validateWorkItem: DispatchWorkItem? + @State private var validateTask: Task? init( initialValue: String?, @@ -120,12 +120,15 @@ struct HexEditorContentView: View { } private func scheduleValidation(_ hex: String) { - validateWorkItem?.cancel() - let workItem = DispatchWorkItem { [hex] in + validateTask?.cancel() + validateTask = Task { @MainActor in + do { + try await Task.sleep(for: .milliseconds(100)) + } catch { + return + } validateHex(hex) } - validateWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: workItem) } private func validateHex(_ hex: String) { diff --git a/TablePro/Views/Results/JSONSyntaxTextView.swift b/TablePro/Views/Results/JSONSyntaxTextView.swift index ee445cfaf..f232241c6 100644 --- a/TablePro/Views/Results/JSONSyntaxTextView.swift +++ b/TablePro/Views/Results/JSONSyntaxTextView.swift @@ -152,7 +152,7 @@ internal struct JSONSyntaxTextView: NSViewRepresentable { var parent: JSONSyntaxTextView var isUpdating = false var braceHelper: JSONBraceMatchingHelper? - private var highlightWorkItem: DispatchWorkItem? + private var highlightTask: Task? private var scrollObserver: NSObjectProtocol? init(_ parent: JSONSyntaxTextView) { @@ -160,7 +160,7 @@ internal struct JSONSyntaxTextView: NSViewRepresentable { } deinit { - highlightWorkItem?.cancel() + highlightTask?.cancel() if let observer = scrollObserver { NotificationCenter.default.removeObserver(observer) } @@ -207,12 +207,15 @@ internal struct JSONSyntaxTextView: NSViewRepresentable { isUpdating = false highlightedSet = IndexSet() - highlightWorkItem?.cancel() - let workItem = DispatchWorkItem { [weak self] in + highlightTask?.cancel() + highlightTask = Task { @MainActor [weak self] in + do { + try await Task.sleep(for: .milliseconds(100)) + } catch { + return + } self?.highlightVisible() } - highlightWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: workItem) } func textViewDidChangeSelection(_ notification: Notification) { diff --git a/TablePro/Views/Results/ResultsJsonView.swift b/TablePro/Views/Results/ResultsJsonView.swift index b30fd0004..c2b68dcdf 100644 --- a/TablePro/Views/Results/ResultsJsonView.swift +++ b/TablePro/Views/Results/ResultsJsonView.swift @@ -16,6 +16,8 @@ internal struct ResultsJsonView: View { @State private var prettyText = "" @State private var cachedJson = "" @State private var copied = false + @State private var renderToken: Int = 0 + @State private var copyCooldownTask: Task? init( tableRows: TableRows, @@ -26,27 +28,14 @@ internal struct ResultsJsonView: View { self._viewMode = State(initialValue: AppSettingsManager.shared.editor.jsonViewerPreferredMode) } - private var allRows: [[String?]] { - tableRows.rows.map(\.values) - } - - private var displayRows: [[String?]] { - let rows = allRows - if selectedRowIndices.isEmpty { - return rows - } - return selectedRowIndices.sorted().compactMap { idx in - rows.indices.contains(idx) ? rows[idx] : nil - } - } - private var rowCountText: String { - let displaying = displayRows.count - let total = tableRows.count - if selectedRowIndices.isEmpty || displaying == total { - return String(format: String(localized: "%d rows"), total) + let rowCount = tableRows.count + let selectedCount = selectedRowIndices.count + let displaying = selectedCount == 0 ? rowCount : selectedCount + if selectedRowIndices.isEmpty || displaying == rowCount { + return String(format: String(localized: "%d rows"), rowCount) } - return String(format: String(localized: "%d of %d rows"), displaying, total) + return String(format: String(localized: "%d of %d rows"), displaying, rowCount) } var body: some View { @@ -56,9 +45,9 @@ internal struct ResultsJsonView: View { content .frame(maxWidth: .infinity, maxHeight: .infinity) } - .onAppear { rebuildJson() } - .onChange(of: selectedRowIndices) { rebuildJson() } - .onChange(of: tableRows.count) { rebuildJson() } + .onAppear { startRebuild() } + .onChange(of: selectedRowIndices) { startRebuild() } + .onChange(of: tableRows.count) { startRebuild() } .onChange(of: viewMode) { AppSettingsManager.shared.editor.jsonViewerPreferredMode = viewMode } @@ -86,8 +75,14 @@ internal struct ResultsJsonView: View { Button { ClipboardService.shared.writeText(cachedJson) copied = true - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { - copied = false + copyCooldownTask?.cancel() + copyCooldownTask = Task { @MainActor in + do { + try await Task.sleep(for: .milliseconds(1500)) + copied = false + } catch { + // cancelled by next press + } } } label: { Label( @@ -151,20 +146,57 @@ internal struct ResultsJsonView: View { // MARK: - JSON Generation - private func rebuildJson() { - let converter = JsonRowConverter(columns: tableRows.columns, columnTypes: tableRows.columnTypes) - let json = converter.generateJson(rows: displayRows) - cachedJson = json - prettyText = json.prettyPrintedAsJson() ?? json - - let result = JSONTreeParser.parse(json) - switch result { - case .success(let node): - parsedTree = node - parseError = nil - case .failure(let error): - parsedTree = nil - parseError = error + private func startRebuild() { + renderToken &+= 1 + let token = renderToken + let columns = tableRows.columns + let columnTypes = tableRows.columnTypes + let rowsSnapshot = tableRows.rows + let selectedIndices = selectedRowIndices + + Task { @MainActor in + let result = await Task.detached(priority: .userInitiated) { + Self.computeJson( + columns: columns, + columnTypes: columnTypes, + rows: rowsSnapshot, + selectedIndices: selectedIndices + ) + }.value + + guard token == renderToken else { return } + cachedJson = result.json + prettyText = result.pretty + switch result.parseResult { + case .success(let node): + parsedTree = node + parseError = nil + case .failure(let error): + parsedTree = nil + parseError = error + } } } + + nonisolated private static func computeJson( + columns: [String], + columnTypes: [ColumnType], + rows: ContiguousArray, + selectedIndices: Set + ) -> (json: String, pretty: String, parseResult: Result) { + let allRows: [[String?]] = rows.map { Array($0.values) } + let displayRows: [[String?]] + if selectedIndices.isEmpty { + displayRows = allRows + } else { + displayRows = selectedIndices.sorted().compactMap { + allRows.indices.contains($0) ? allRows[$0] : nil + } + } + let converter = JsonRowConverter(columns: columns, columnTypes: columnTypes) + let json = converter.generateJson(rows: displayRows) + let pretty = json.prettyPrintedAsJson() ?? json + let parseResult = JSONTreeParser.parse(json) + return (json: json, pretty: pretty, parseResult: parseResult) + } } diff --git a/TablePro/Views/Results/RowVisualIndex.swift b/TablePro/Views/Results/RowVisualIndex.swift new file mode 100644 index 000000000..dea83e74a --- /dev/null +++ b/TablePro/Views/Results/RowVisualIndex.swift @@ -0,0 +1,107 @@ +// +// RowVisualIndex.swift +// TablePro +// + +import Foundation + +@MainActor +final class RowVisualIndex { + private var states: [Int: RowVisualState] = [:] + + func visualState(for row: Int) -> RowVisualState { + states[row] ?? .empty + } + + func clear() { + states.removeAll(keepingCapacity: true) + } + + func rebuild(from changeManager: AnyChangeManager, sortedIDs: [RowID]?) { + states.removeAll(keepingCapacity: true) + + let insertedRowIndices = Self.insertedRowIndices( + from: changeManager, + sortedIDs: sortedIDs + ) + + if !changeManager.hasChanges && insertedRowIndices.isEmpty { + return + } + + for rowChange in changeManager.rowChanges { + states[rowChange.rowIndex] = Self.makeState( + for: rowChange, + inserted: insertedRowIndices.contains(rowChange.rowIndex) + ) + } + + for rowIndex in insertedRowIndices where states[rowIndex] == nil { + states[rowIndex] = RowVisualState( + isDeleted: false, + isInserted: true, + modifiedColumns: [] + ) + } + } + + func updateRow(_ rowIndex: Int, from changeManager: AnyChangeManager, sortedIDs: [RowID]?) { + let isInsertedDisplay = Self.isRowInsertedAtDisplayIndex( + rowIndex, + changeManager: changeManager, + sortedIDs: sortedIDs + ) + + if let rowChange = changeManager.rowChanges.first(where: { $0.rowIndex == rowIndex }) { + states[rowIndex] = Self.makeState(for: rowChange, inserted: isInsertedDisplay) + return + } + + if isInsertedDisplay { + states[rowIndex] = RowVisualState( + isDeleted: false, + isInserted: true, + modifiedColumns: [] + ) + } else { + states.removeValue(forKey: rowIndex) + } + } + + private static func makeState(for rowChange: RowChange, inserted: Bool) -> RowVisualState { + let isDeleted = rowChange.type == .delete + let isInserted = inserted || rowChange.type == .insert + let modifiedColumns: Set = rowChange.type == .update + ? Set(rowChange.cellChanges.map { $0.columnIndex }) + : [] + return RowVisualState( + isDeleted: isDeleted, + isInserted: isInserted, + modifiedColumns: modifiedColumns + ) + } + + private static func insertedRowIndices( + from changeManager: AnyChangeManager, + sortedIDs: [RowID]? + ) -> Set { + guard let sortedIDs else { return changeManager.insertedRowIndices } + var indices = Set() + for (displayIndex, id) in sortedIDs.enumerated() where id.isInserted { + indices.insert(displayIndex) + } + return indices + } + + private static func isRowInsertedAtDisplayIndex( + _ rowIndex: Int, + changeManager: AnyChangeManager, + sortedIDs: [RowID]? + ) -> Bool { + if let sortedIDs { + guard rowIndex >= 0, rowIndex < sortedIDs.count else { return false } + return sortedIDs[rowIndex].isInserted + } + return changeManager.insertedRowIndices.contains(rowIndex) + } +} diff --git a/TablePro/Views/Results/TableRowViewWithMenu.swift b/TablePro/Views/Results/TableRowViewWithMenu.swift deleted file mode 100644 index 3ef63dd90..000000000 --- a/TablePro/Views/Results/TableRowViewWithMenu.swift +++ /dev/null @@ -1,313 +0,0 @@ -// -// TableRowViewWithMenu.swift -// TablePro -// -// Custom row view with context menu support. -// Extracted from DataGridView for better maintainability. -// - -import AppKit - -/// Custom row view that provides context menu for row operations -final class TableRowViewWithMenu: NSTableRowView { - weak var coordinator: TableViewCoordinator? - var rowIndex: Int = 0 - - override func menu(for event: NSEvent) -> NSMenu? { - guard let coordinator = coordinator, - let tableView = coordinator.tableView else { return nil } - - // Determine which column was clicked - let locationInRow = convert(event.locationInWindow, from: nil) - let locationInTable = tableView.convert(locationInRow, from: self) - let clickedColumn = tableView.column(at: locationInTable) - - let dataColumnIndex: Int = clickedColumn > 0 - ? DataGridView.dataColumnIndex(for: clickedColumn, in: tableView, schema: coordinator.identitySchema) ?? -1 - : -1 - - let menu = NSMenu() - - if coordinator.changeManager.isRowDeleted(rowIndex) { - menu.addItem( - withTitle: String(localized: "Undo Delete"), action: #selector(undoDeleteRow), keyEquivalent: "" - ).target = self - } - - if !coordinator.changeManager.isRowDeleted(rowIndex) { - // Copy - let copyItem = NSMenuItem( - title: String(localized: "Copy"), action: #selector(copySelectedOrCurrentRow), keyEquivalent: "") - copyItem.target = self - menu.addItem(copyItem) - - // "Copy as" submenu — always includes Cell Value + With Headers, conditionally adds SQL statements - let copyAsMenu = NSMenu() - - if dataColumnIndex >= 0 { - let copyCellItem = NSMenuItem( - title: String(localized: "Cell Value"), action: #selector(copyCellValue(_:)), - keyEquivalent: "") - copyCellItem.representedObject = dataColumnIndex - copyCellItem.target = self - copyAsMenu.addItem(copyCellItem) - } - - let copyWithHeadersItem = NSMenuItem( - title: String(localized: "With Headers"), - action: #selector(copySelectedOrCurrentRowWithHeaders), - keyEquivalent: "") - copyWithHeadersItem.target = self - copyAsMenu.addItem(copyWithHeadersItem) - - let jsonItem = NSMenuItem( - title: String(localized: "JSON"), - action: #selector(copyAsJson), - keyEquivalent: "") - jsonItem.target = self - copyAsMenu.addItem(jsonItem) - - if let dbType = coordinator.databaseType, - dbType != .mongodb && dbType != .redis, - coordinator.tableName != nil { - copyAsMenu.addItem(NSMenuItem.separator()) - - let insertItem = NSMenuItem( - title: String(localized: "INSERT Statement(s)"), - action: #selector(copyAsInsert), - keyEquivalent: "") - insertItem.target = self - copyAsMenu.addItem(insertItem) - - let updateItem = NSMenuItem( - title: String(localized: "UPDATE Statement(s)"), - action: #selector(copyAsUpdate), - keyEquivalent: "") - updateItem.target = self - copyAsMenu.addItem(updateItem) - } - - let copyAsItem = NSMenuItem(title: String(localized: "Copy as"), action: nil, keyEquivalent: "") - copyAsItem.submenu = copyAsMenu - menu.addItem(copyAsItem) - - // Paste - if coordinator.isEditable { - let pasteItem = NSMenuItem( - title: String(localized: "Paste"), action: #selector(pasteRows), keyEquivalent: "") - pasteItem.target = self - menu.addItem(pasteItem) - } - - let tableRows = coordinator.tableRowsProvider() - if dataColumnIndex >= 0, dataColumnIndex < tableRows.columns.count { - let columnName = tableRows.columns[dataColumnIndex] - if let fkInfo = tableRows.columnForeignKeys[columnName], - let cellValue = coordinator.cellValue(at: rowIndex, column: dataColumnIndex), - !cellValue.isEmpty { - menu.addItem(NSMenuItem.separator()) - - let previewItem = NSMenuItem( - title: String(localized: "Preview Referenced Row"), - action: #selector(previewForeignKey(_:)), - keyEquivalent: "" - ) - previewItem.representedObject = dataColumnIndex - previewItem.target = self - menu.addItem(previewItem) - - let navItem = NSMenuItem( - title: String(format: String(localized: "Open %@"), fkInfo.referencedTable), - action: #selector(navigateToForeignKey(_:)), - keyEquivalent: "" - ) - navItem.representedObject = dataColumnIndex - navItem.target = self - menu.addItem(navItem) - } - } - - if coordinator.isEditable { - menu.addItem(NSMenuItem.separator()) - } - - if coordinator.isEditable && dataColumnIndex >= 0 { - let setValueMenu = NSMenu() - - let emptyItem = NSMenuItem( - title: String(localized: "Empty"), action: #selector(setEmptyValue(_:)), keyEquivalent: "") - emptyItem.representedObject = dataColumnIndex - emptyItem.target = self - setValueMenu.addItem(emptyItem) - - let columnName = dataColumnIndex < tableRows.columns.count - ? tableRows.columns[dataColumnIndex] - : nil - - let isNullable = columnName.flatMap { tableRows.columnNullable[$0] } ?? true - if isNullable { - let nullItem = NSMenuItem( - title: String(localized: "NULL"), action: #selector(setNullValue(_:)), keyEquivalent: "") - nullItem.representedObject = dataColumnIndex - nullItem.target = self - setValueMenu.addItem(nullItem) - } - - let hasDefault = columnName.flatMap({ tableRows.columnDefaults[$0] ?? nil }) != nil - if hasDefault { - let defaultItem = NSMenuItem( - title: String(localized: "Default"), action: #selector(setDefaultValue(_:)), keyEquivalent: "") - defaultItem.representedObject = dataColumnIndex - defaultItem.target = self - setValueMenu.addItem(defaultItem) - } - - let setValueItem = NSMenuItem(title: String(localized: "Set Value"), action: nil, keyEquivalent: "") - setValueItem.submenu = setValueMenu - menu.addItem(setValueItem) - } - - // Export Results - menu.addItem(NSMenuItem.separator()) - - let exportItem = NSMenuItem( - title: String(localized: "Export Results..."), - action: #selector(exportResults), - keyEquivalent: "" - ) - exportItem.target = self - menu.addItem(exportItem) - - // Duplicate & Delete - if coordinator.isEditable { - let duplicateItem = NSMenuItem( - title: String(localized: "Duplicate"), action: #selector(duplicateRow), keyEquivalent: "") - duplicateItem.target = self - menu.addItem(duplicateItem) - - let deleteItem = NSMenuItem( - title: String(localized: "Delete"), - action: #selector(deleteRow), - keyEquivalent: "" - ) - deleteItem.target = self - menu.addItem(deleteItem) - } - } - - return menu - } - - @objc private func deleteRow() { - let indices: Set = if let selected = coordinator?.selectedRowIndices, !selected.isEmpty { - selected - } else { - [rowIndex] - } - coordinator?.delegate?.dataGridDeleteRows(indices) - } - - @objc private func duplicateRow() { - coordinator?.delegate?.dataGridDuplicateRow() - } - - @objc private func undoDeleteRow() { - coordinator?.undoDeleteRow(at: rowIndex) - } - - @objc private func undoInsertRow() { - coordinator?.undoInsertRow(at: rowIndex) - } - - @objc private func copySelectedOrCurrentRowWithHeaders() { - guard let coordinator = coordinator else { return } - let indices: Set = !coordinator.selectedRowIndices.isEmpty - ? coordinator.selectedRowIndices - : [rowIndex] - coordinator.copyRowsWithHeaders(at: indices) - } - - @objc private func copySelectedOrCurrentRow() { - guard let coordinator = coordinator else { return } - let indices: Set = !coordinator.selectedRowIndices.isEmpty - ? coordinator.selectedRowIndices - : [rowIndex] - coordinator.delegate?.dataGridCopyRows(indices) - } - - @objc private func pasteRows() { - coordinator?.delegate?.dataGridPasteRows() - } - - @objc private func copyCellValue(_ sender: NSMenuItem) { - guard let columnIndex = sender.representedObject as? Int else { return } - coordinator?.copyCellValue(at: rowIndex, columnIndex: columnIndex) - } - - @objc private func setNullValue(_ sender: NSMenuItem) { - guard let columnIndex = sender.representedObject as? Int else { return } - coordinator?.setCellValueAtColumn(nil, at: rowIndex, columnIndex: columnIndex) - } - - @objc private func setEmptyValue(_ sender: NSMenuItem) { - guard let columnIndex = sender.representedObject as? Int else { return } - coordinator?.setCellValueAtColumn("", at: rowIndex, columnIndex: columnIndex) - } - - @objc private func setDefaultValue(_ sender: NSMenuItem) { - guard let columnIndex = sender.representedObject as? Int else { return } - coordinator?.setCellValueAtColumn("__DEFAULT__", at: rowIndex, columnIndex: columnIndex) - } - - @objc private func copyAsInsert() { - guard let coordinator else { return } - let indices: Set = !coordinator.selectedRowIndices.isEmpty - ? coordinator.selectedRowIndices - : [rowIndex] - coordinator.copyRowsAsInsert(at: indices) - } - - @objc private func copyAsUpdate() { - guard let coordinator else { return } - let indices: Set = !coordinator.selectedRowIndices.isEmpty - ? coordinator.selectedRowIndices - : [rowIndex] - coordinator.copyRowsAsUpdate(at: indices) - } - - @objc private func exportResults() { - NotificationCenter.default.post(name: .exportQueryResults, object: nil) - } - - @objc private func copyAsJson() { - guard let coordinator else { return } - let indices: Set = !coordinator.selectedRowIndices.isEmpty - ? coordinator.selectedRowIndices - : [rowIndex] - coordinator.copyRowsAsJson(at: indices) - } - - @objc private func previewForeignKey(_ sender: NSMenuItem) { - guard let columnIndex = sender.representedObject as? Int, - let coordinator, let tableView = coordinator.tableView, - let column = DataGridView.tableColumnIndex( - for: columnIndex, - in: tableView, - schema: coordinator.identitySchema - ) else { return } - coordinator.showForeignKeyPreview( - tableView: tableView, row: rowIndex, column: column, columnIndex: columnIndex - ) - } - - @objc private func navigateToForeignKey(_ sender: NSMenuItem) { - guard let columnIndex = sender.representedObject as? Int, - let coordinator else { return } - let tableRows = coordinator.tableRowsProvider() - guard columnIndex >= 0, columnIndex < tableRows.columns.count else { return } - let columnName = tableRows.columns[columnIndex] - guard let fkInfo = tableRows.columnForeignKeys[columnName], - let value = coordinator.cellValue(at: rowIndex, column: columnIndex) else { return } - coordinator.delegate?.dataGridNavigateFK(value: value, fkInfo: fkInfo) - } -} diff --git a/TablePro/Views/Results/TableSelection.swift b/TablePro/Views/Results/TableSelection.swift index 0d35e6858..545dccd8c 100644 --- a/TablePro/Views/Results/TableSelection.swift +++ b/TablePro/Views/Results/TableSelection.swift @@ -4,20 +4,6 @@ struct TableSelection: Equatable { var focusedRow: Int = -1 var focusedColumn: Int = -1 - var hasFocus: Bool { focusedRow >= 0 && focusedColumn >= 0 } - - static let empty = TableSelection() - - mutating func clearFocus() { - focusedRow = -1 - focusedColumn = -1 - } - - mutating func setFocus(row: Int, column: Int) { - focusedRow = row - focusedColumn = column - } - func reloadIndexes(from previous: TableSelection) -> (rows: IndexSet, columns: IndexSet)? { guard previous.focusedRow != focusedRow || previous.focusedColumn != focusedColumn else { return nil diff --git a/TablePro/Views/Results/TableViewCoordinating.swift b/TablePro/Views/Results/TableViewCoordinating.swift deleted file mode 100644 index eb526271a..000000000 --- a/TablePro/Views/Results/TableViewCoordinating.swift +++ /dev/null @@ -1,16 +0,0 @@ -import Foundation - -@MainActor -protocol TableViewCoordinating: AnyObject { - func applyInsertedRows(_ indices: IndexSet) - func applyRemovedRows(_ indices: IndexSet) - func applyFullReplace() - func applyDelta(_ delta: Delta) - func invalidateCachesForUndoRedo() - func commitActiveCellEdit() - func beginEditing(displayRow: Int, column: Int) - func refreshForeignKeyColumns() - func scrollToTop() -} - -extension TableViewCoordinator: TableViewCoordinating {} diff --git a/TablePro/Views/RightSidebar/FieldEditors/JsonEditorView.swift b/TablePro/Views/RightSidebar/FieldEditors/JsonEditorView.swift index a920b11d7..fa8e08b05 100644 --- a/TablePro/Views/RightSidebar/FieldEditors/JsonEditorView.swift +++ b/TablePro/Views/RightSidebar/FieldEditors/JsonEditorView.swift @@ -22,8 +22,7 @@ internal struct JsonEditorView: View { Image(systemName: "arrow.up.forward.app") .font(.caption2) .padding(4) - .background(.ultraThinMaterial) - .clipShape(RoundedRectangle(cornerRadius: 4)) + .themeMaterial(.inlineControl, .ultraThinMaterial, in: RoundedRectangle(cornerRadius: 4)) } .buttonStyle(.borderless) .help(String(localized: "Open in Window")) @@ -33,8 +32,7 @@ internal struct JsonEditorView: View { Image(systemName: "arrow.up.left.and.arrow.down.right") .font(.caption2) .padding(4) - .background(.ultraThinMaterial) - .clipShape(RoundedRectangle(cornerRadius: 4)) + .themeMaterial(.inlineControl, .ultraThinMaterial, in: RoundedRectangle(cornerRadius: 4)) } .buttonStyle(.borderless) .help(String(localized: "Expand in Sidebar")) diff --git a/TablePro/Views/Settings/AccountSettingsView.swift b/TablePro/Views/Settings/AccountSettingsView.swift index 30e185756..2d541480b 100644 --- a/TablePro/Views/Settings/AccountSettingsView.swift +++ b/TablePro/Views/Settings/AccountSettingsView.swift @@ -36,7 +36,7 @@ struct AccountSettingsView: View { .controlSize(.small) } .padding(12) - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 8)) + .themeMaterial(.banner, .ultraThinMaterial, in: RoundedRectangle(cornerRadius: 8)) .padding() Spacer() diff --git a/TablePro/Views/Settings/Sections/MCPTokenRevealSheet.swift b/TablePro/Views/Settings/Sections/MCPTokenRevealSheet.swift index 25715f270..389070c0b 100644 --- a/TablePro/Views/Settings/Sections/MCPTokenRevealSheet.swift +++ b/TablePro/Views/Settings/Sections/MCPTokenRevealSheet.swift @@ -45,7 +45,7 @@ struct MCPTokenRevealSheet: View { } .padding(12) .frame(maxWidth: .infinity, alignment: .leading) - .background(.thinMaterial) + .themeMaterial(.banner, .thinMaterial) .overlay( RoundedRectangle(cornerRadius: 8) .strokeBorder(Color(nsColor: .systemOrange), lineWidth: 1) diff --git a/TablePro/Views/Sidebar/NativeSearchField.swift b/TablePro/Views/Sidebar/NativeSearchField.swift index 582f5f618..41d4ca621 100644 --- a/TablePro/Views/Sidebar/NativeSearchField.swift +++ b/TablePro/Views/Sidebar/NativeSearchField.swift @@ -83,12 +83,13 @@ struct NativeSearchField: NSViewRepresentable { func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { if commandSelector == #selector(NSResponder.cancelOperation(_:)) { - if let field = control as? NSSearchField { + guard let field = control as? NSSearchField else { return false } + if !field.stringValue.isEmpty { field.stringValue = "" + text.wrappedValue = "" + return true } - text.wrappedValue = "" - control.window?.makeFirstResponder(nil) - return true + return false } if commandSelector == #selector(NSResponder.moveUp(_:)), let onMoveUp { onMoveUp() diff --git a/docs/refactor/datagrid-native-rewrite/00-blueprint.md b/docs/refactor/datagrid-native-rewrite/00-blueprint.md new file mode 100644 index 000000000..a3f865a2c --- /dev/null +++ b/docs/refactor/datagrid-native-rewrite/00-blueprint.md @@ -0,0 +1,912 @@ +# 00 - Master Blueprint: TablePro DataGrid Native Rewrite + +Source of truth for the native AppKit rewrite of the TablePro DataGrid. Every commit in this rewrite cites a stage from §5 of this document. Reconciles 9 specialist reports (`01-rendering.md` through `09-dead-redundant.md`) and the prior performance audit (`~/Downloads/DATAGRID_PERFORMANCE_AUDIT.md`) against the project rules in `TablePro/CLAUDE.md`. + +Author: synthesizer (task #10). Date frozen: 2026-05-08. + +--- + +## 1. Executive verdict + +The DataGrid renders correct AppKit foundations (view-based `NSTableView`, fixed row height, `makeView(withIdentifier:)` reuse, modern drag and drop) but layers seven anti-patterns on top. A single visible viewport (30 rows × 20 columns = 600 cells) currently allocates 2,400 to 3,600 `CALayer` instances, runs 600 `CATransaction` open/commit pairs per `reloadData()`, formats every cell on the main thread inside `tableView(_:viewFor:row:)`, performs `O(n²)` row lookups during sort layout, and holds an unbounded `[RowID: [String?]]` display cache that can climb past 2 GB resident on a 1M-row × 20-column scan. The plugin boundary forces a full `[[String?]]` copy per page and the grid never uses the streaming variant the export pipeline already consumes. + +Target: collapse the cell to one `NSView` with one layer, move all formatting to an `actor CellDisplayWarmer` consumed via an `actor StreamingDataGridStore`, replace `NSViewRepresentable` boilerplate with `NSViewControllerRepresentable` plus a `Snapshot`+`lastApplied` diff, adopt the streaming `PluginStreamElement` envelope across the plugin ABI (one bump), and delete five files (700+ lines) that AppKit primitives replace. After landing: visible viewport = 601 layers, zero per-cell `CATransaction`, zero formatting on main during scroll, `O(1)` `RowID → Int` lookup, `NSCache`-bounded display memory at 32 MB regardless of result size, first-paint latency under 500 ms on 1M-row queries. + +--- + +## 2. Reconciled contradictions + +Eight points where the specialist reports disagree, with verdicts. + +| # | Contradiction | Verdict | Reason | Right / Wrong / Citation | +|---|---|---|---|---| +| 1 | NSPredicateEditor vs FilterPanelView | **MIGRATE to NSPredicateEditor + write `NSPredicate`-to-dialect-SQL visitor** (user decision 2026-05-08) | The native AppKit primitive at the user-facing surface gets accessibility, Reduce Transparency / Increase Contrast, VoiceOver, and HIG conformance for free. The dialect-specific SQL translation moves into a `PredicateSQLEmitter` visitor that handles MySQL backticks vs Postgres double-quotes vs MSSQL brackets, `LIKE` wildcard cases, `BETWEEN` with two scalars, and the `__RAW__` raw-SQL escape via a custom `NSPredicateEditorRowTemplate`. Stage 14 owns this migration. | HIG (06-hig-design-system.md H4) right on the surface; custom-vs-native (08-custom-vs-native.md A6) right that translation is the work item. Cite `AppKit/NSPredicateEditor.h`, `AppKit/NSPredicateEditorRowTemplate.h`, `Foundation/NSPredicate.h`, TablePro `Views/Filter/FilterPanelView.swift:8-248`. | +| 2 | NSPopUpButton/NSComboBox vs custom enum/FK pickers | **KEEP custom popovers, but rebuild content as native AppKit (NSPopover + NSStackView + NSSearchField + NSTableView)** | `NSPopUpButton` is non-searchable and breaks past ~30 enum members. `NSComboBox` allows free-text entry which corrupts FK values (must be one of the predefined rows). The right native primitive is `NSPopover` containing an `NSViewController` whose root is `NSStackView { NSSearchField, NSScrollView { NSTableView } }`. That eliminates the SwiftUI list, drops `NativeSearchField` from these two callers, and removes the `.onKeyPress(.return)` shim in `EnumPopoverContentView`/`ForeignKeyPopoverContentView`. | custom-vs-native (08 A8/A9) right on "do not switch to NSPopUpButton/NSComboBox"; SwiftUI-interop (05 S8) right on "the SwiftUI list is the wrong content"; HIG (06 H5) wrong on its primitive choice. Cite `AppKit/NSPopover.h`, `AppKit/NSTableView.h`, TablePro `Views/Results/EnumPopoverContentView.swift:12-99`, `Views/Results/ForeignKeyPopoverContentView.swift:12-184`. | +| 3 | ConnectionDataCache: NSCache vs NSMapTable | **NSMapTable.weakToWeakObjects** | `NSCache` (`Foundation/NSCache.h`) auto-evicts under memory pressure. The cached value is a long-lived `@Observable` view model with active `@Bindable` SwiftUI subscriptions and Combine `cancellables`. Eviction would silently invalidate live bindings and detach observers - a correctness bug, not a performance win. `NSMapTable` constructed with `.strongMemory` keys and `.weakMemory` values (`Foundation/NSMapTable.h`) deallocates the cache only when no SwiftUI view holds a reference, which is the correct semantic. | custom-vs-native (08 N1) right; memory (07 §9) wrong. Cite `Foundation/NSMapTable.h`, TablePro `ViewModels/ConnectionDataCache.swift:13`. | +| 4 | SortableHeaderView vs sortDescriptorPrototype | **REPLACE for single-column sort path; KEEP custom drawing only for multi-column priority badges** | AppKit's `NSTableColumn.sortDescriptorPrototype` plus `tableView(_:sortDescriptorsDidChange:)` natively handles click detection, modifier-aware shift-click multi-key append, and the standard chevron indicator. The 288-line `SortableHeaderView` reimplements all of that. The one feature it adds that AppKit does not is multi-column sort priority badges ("1↑ 2↓"). The Apple-correct shape is: stock `NSTableHeaderView` plus `sortDescriptorPrototype` on every column, and the `NSTableHeaderCell.drawSortIndicator(withFrame:in:ascending:priority:)` API already draws priority numbers when `priority > 0`. We do NOT need a custom header class for this. The custom `mouseDown:`, `resetCursorRects`, `mouseMoved:`, and `updateTrackingAreas` overrides are pure dead weight - `NSTableHeaderView` already handles resize cursors when `column.resizingMask.contains(.userResizingMask)`, which `DataGridColumnPool.swift:86` already sets. | NSTableView API (03 T2/T16) right; custom-vs-native (08 A5) partially right (multi-sort dispatch is custom, drawing is not). The correct verdict combines them: replace the custom view + cell, keep the multi-sort *dispatch logic* in the delegate's `sortDescriptorsDidChange` callback. Cite `AppKit/NSTableColumn.h` `sortDescriptorPrototype`, `AppKit/NSTableHeaderCell.h` `drawSortIndicator`, TablePro `Views/Results/SortableHeaderView.swift:84-287`, `Views/Results/SortableHeaderCell.swift:32-110`. | +| 5 | CellOverlayEditor vs native field editor | **REPLACE with native field editor returned via `windowWillReturnFieldEditor:to:`** | The custom `CellOverlayEditor` builds a borderless `NSPanel` in screen coordinates and observes `boundsDidChangeNotification`/`columnDidResizeNotification` to dismiss on scroll/resize. AppKit's documented pattern for "replace the default single-line field editor with a multi-line `NSTextView`" is `NSWindowDelegate.windowWillReturnFieldEditor(_:to:)` returning a long-lived shared `NSTextView` with `isFieldEditor = true`. That editor lives inside the cell rect (placed there by `editColumn:row:with:select:`), follows scroll and column resize automatically, routes Return/Esc/Tab through the existing `NSControlTextEditingDelegate.control(_:textView:doCommandBy:)` implementation, and commits on blur via `NSControlTextEditingDelegate.control(_:textShouldEndEditing:)` for free. Sequel-Ace ships exactly this pattern: `Sequel-Ace/Source/Views/TextViews/SPTextView.{h,m}` is a long-lived multi-line `NSTextView` returned as the field editor for SQL editing controls. | NSTableView API (03 T3) right; rendering report (01 §3 reference to Gridex `EditContainerView`) compatible (Gridex's editor on tableView is a stylistic alternative; the field-editor route is more conservative and matches Sequel-Ace). Custom-vs-native (08 A4) wrong - `NSPanel.nonactivatingPanel` IS a native primitive but it is not the right one for in-cell editing; it is the right one for floating tool palettes, which is not what we have. Cite `AppKit/NSWindow.h` `windowWillReturnFieldEditor:to:`, `AppKit/NSTextView.h` `isFieldEditor`, `AppKit/NSTableView.h` `editColumn:row:with:select:`, Sequel-Ace `Source/Views/TextViews/SPTextView.{h,m}`, TablePro `Views/Results/CellOverlayEditor.swift:13-243`. | +| 6 | EditorTabBar live vs deleted | **EditorTabBar is GONE; CLAUDE.md is stale** | `grep -rn "EditorTabBar" --include="*.swift"` returns zero results in the current tree. CLAUDE.md still claims `EditorTabBar - pure SwiftUI tab bar`. The audit (DATAGRID_PERFORMANCE_AUDIT.md row H1) and HIG report (06 H1) treat it as live. Editor tabs are now native NSWindow tabs (`NSWindow.tabbingMode = .preferred` at `Core/Services/Infrastructure/TabWindowController.swift:60-64`). | custom-vs-native (08 A3) right; audit and HIG report wrong. Cite `AppKit/NSWindow.h` `tabbingMode`, TablePro `Core/Services/Infrastructure/TabWindowController.swift:60-64`, CLAUDE.md "Editor Architecture" bullet. | +| 7 | JSONHighlightPatterns regex caching | **RESOLVED - already cached, audit M5 was wrong** | File at `Views/Results/JSONHighlightPatterns.swift:18-22` declares `static let string`, `static let key`, `static let number`, `static let booleanNull`, each calling `compileJSONRegex(...)` once per type. Swift `static let` is `dispatch_once`-equivalent, thread-safe, never recompiled. Verified by direct grep. | memory (07 §5) right; audit M5 wrong. Cite TablePro `Views/Results/JSONHighlightPatterns.swift:18-22`. | +| 8 | Accessibility row/column index ranges | **RESOLVED - already shipping, audit H8 was wrong** | `Cells/DataGridBaseCellView.swift:130-131` calls `setAccessibilityRowIndexRange(NSRange(location: state.row, length: 1))` and `setAccessibilityColumnIndexRange(NSRange(location: state.columnIndex, length: 1))`. Verified by direct grep. | rendering (01 R10) right; HIG (06 H9) right; audit H8 wrong. Cite `AppKit/NSAccessibilityProtocols.h`, TablePro `Views/Results/Cells/DataGridBaseCellView.swift:130-131`. | + +--- + +## 3. Audit corrections (truth table) + +For every audit item that the team marked stale or wrong, list the original claim, the ground truth, and the evidence. These corrections need to flow back into `DATAGRID_PERFORMANCE_AUDIT.md` if it is ever re-run. + +| Audit ID | Original audit claim | Ground truth | Evidence | +|---|---|---|---| +| H1 | "Custom `ResultTabBar` and `EditorTabBar`" | `EditorTabBar` does not exist. `ResultTabBar` is live. Editor tabs use native `NSWindow.tabbingMode = .preferred`. | `Core/Services/Infrastructure/TabWindowController.swift:60-64`, `grep -rn "EditorTabBar" --include="*.swift"` returns zero. | +| H8 | "Cells do not announce 'row X of Y' to VoiceOver" | Cells DO set `setAccessibilityRowIndexRange` and `setAccessibilityColumnIndexRange`. | `Views/Results/Cells/DataGridBaseCellView.swift:130-131`. | +| M5 | "`static let regex = try! NSRegularExpression(...)` not used; compiled per pass" | `JSONHighlightPatterns` declares all four regexes as `static let`, lazily initialized once. | `Views/Results/JSONHighlightPatterns.swift:18-22`. | +| C6 | "`displayCache` mutation not atomic if accessed concurrently" | `displayCache` is mutated only on `@MainActor`; `TableViewCoordinator` is `@MainActor`, all callers are too. The contract is silent, not broken. | `Views/Results/DataGridCoordinator.swift:8` (`final class TableViewCoordinator: NSObject, NSTableViewDelegate, ...`). The risk is documentation, not data races today. | +| (audit table 2.6 referenced "tab replacement guard" as broken) | n/a - the guard is documented in CLAUDE.md as an invariant and operates correctly | The active-work check runs before the preview-tab branch, per CLAUDE.md "Tab replacement guard" invariant. | CLAUDE.md "Invariants" section. | +| (audit footnote on `usesAutomaticRowHeights`) | Implies risk of accidental enable | Not set anywhere; AppKit default is `false`. | `Views/Results/DataGridView.swift:51` does not assign it. Recommended (T7) to set explicitly to make the contract visible. | + +CLAUDE.md edit required at the "Editor Architecture" bullet to remove the `EditorTabBar` reference. See §5 stage 13 for the explicit edit. + +--- + +## 4. Target architecture + +Layered diagram. Each layer names the framework, the protocol/file in the new design, the threading model, and the ownership story. + +``` ++----------------------------------------------------------+ +| Plugin process | +| (loaded by PluginManager from .tableplugin bundle) | +| | +| PluginDatabaseDriver | +| func executeStreamingQuery(_:rowCap:parameters:) | +| -> AsyncThrowingStream| +| | +| PluginStreamElement (already exists for export): | +| case header(PluginStreamHeader) | +| case rows([PluginRow]) | +| case metadata(executionTime, isTruncated, ...) | +| | +| Threading: plugin's own queue / OracleNIO event loop | +| Sendable: yes (Codable values across bundle boundary) | ++--------------------------+-------------------------------+ + | + | AsyncThrowingStream + v ++----------------------------------------------------------+ +| In-process bridge | +| | +| PluginDriverAdapter (Core/Plugins/PluginDriverAdapter) | +| bridges PluginDatabaseDriver -> DatabaseDriver | +| | +| Threading: nonisolated, called via await from MainActor | ++--------------------------+-------------------------------+ + | + v ++----------------------------------------------------------+ +| Data layer | +| | +| actor StreamingDataGridStore | +| var rows: ContiguousArray | +| var indexByID: [RowID: Int] // O(1) | +| let displayCache: NSCache | +| let changes: AsyncStream | +| | +| func cellDisplay(at:column:) -> CellDisplay | +| func prefetchRows(in:) async | +| func replaceCell(at:column:with:) async | +| func appendInsertedRow(values:) async -> Int | +| | +| Threading: actor isolation | +| Apple API: SE-0306 actor, SE-0314 AsyncStream, | +| Foundation/NSCache.h | +| File: TablePro/Core/DataGrid/StreamingDataGridStore.swift| ++--------------------------+-------------------------------+ + | + v ++----------------------------------------------------------+ +| Display layer | +| | +| actor CellDisplayWarmer | +| func warm(chunk:[PluginRow], columnTypes:, | +| displayFormats:, previewLength:Int) | +| -> ContiguousArray> | +| | +| CellDisplayFormatter (nonisolated, was @MainActor) | +| DateFormattingService (nonisolated, was @MainActor) | +| BlobFormattingService (nonisolated, was @MainActor) | +| | +| Threading: actor isolation, called from store actor | +| Apple API: SE-0306 actor | +| File: TablePro/Core/DataGrid/CellDisplayWarmer.swift | ++--------------------------+-------------------------------+ + | AsyncStream + v ++----------------------------------------------------------+ +| Coordinator (replaces TableViewCoordinator + extensions)| +| | +| @MainActor final class DataGridViewController : | +| NSViewController | +| | +| // owns: | +| let scrollView: NSScrollView | +| let tableView: KeyHandlingTableView | +| let dataSource: DataGridDataSource | +| let delegate: DataGridDelegate | +| let fieldEditor: DataGridFieldEditorController | +| let columnPool: DataGridColumnPool | +| let visualIndex: RowVisualIndex | +| let store: any DataGridStore | +| | +| // lifecycle: | +| override viewDidLoad() | +| override viewWillAppear() | +| override viewDidDisappear() | +| | +| func bind(to store: DataGridStore) | +| func apply(_ snapshot: Snapshot) | +| | +| Threading: @MainActor | +| Apple API: AppKit/NSViewController.h, AppKit/NSTableView.h| +| File: TablePro/Views/Results/DataGridViewController.swift| ++----+--------------+--------------+--------------+--------+ + | | | | + v v v v ++----------+ +-----------+ +------------+ +---------------+ +|DataSource| |Delegate | |FieldEditor | |ColumnPool | +| | | | |Controller | | | +|numberOf | |viewFor: | |windowWill- | |reconcile | +|Rows | |row: | |Return- | |columns | +|sortDesc- | |rowViewFor:| |FieldEditor:| | | +|Changed | |row: | |to: | | | +|paste- | |should- | |control(_: | | | +|board- | |Edit:row: | |textView: | | | +|Writer- | |sizeToFit: | |doCommandBy:| | +|ForRow: | |menuNeeds- | |) | | +|drag/drop | |Update: | | | | +|valdat- | |type- | | | | +|ion | |Select- | | | | +| | |StringFor: | | | | +|@MainActor| |@MainActor | |@MainActor | |@MainActor | ++----------+ +-----------+ +------------+ +---------------+ + | + v ++----------------------------------------------------------+ +| Bridge to SwiftUI | +| | +| struct DataGridView: NSViewControllerRepresentable { | +| typealias NSViewControllerType = DataGridViewController| +| | +| func makeNSViewController(context:) | +| -> DataGridViewController | +| func updateNSViewController(_:context:) | +| let snapshot = Snapshot(...) | +| guard snapshot != context.coordinator.lastApplied| +| else { return } | +| controller.apply(snapshot) | +| context.coordinator.lastApplied = snapshot | +| func dismantleNSViewController(_:coordinator:) | +| | +| class Coordinator { var lastApplied: Snapshot? } | +| } | +| | +| Threading: @MainActor (NSViewControllerRepresentable | +| is @MainActor by default) | +| Apple API: SwiftUI/NSViewControllerRepresentable | +| File: TablePro/Views/Results/DataGridView.swift | ++--------------------------+-------------------------------+ + | + v ++----------------------------------------------------------+ +| Cell layer (one type per kind, one layer per cell) | +| | +| final class DataGridCellView: NSView { | +| override init(frame:) | +| wantsLayer = true | +| layerContentsRedrawPolicy = .onSetNeedsDisplay | +| canDrawSubviewsIntoLayer = true | +| // suppress implicit layer animations: | +| // override action(for:forKey:) -> NSNull | +| | +| override func draw(_ dirtyRect: NSRect) | +| // 1. fill changeBackground if set | +| // color.setFill(); bounds.fill() | +| // 2. draw cached NSAttributedString | +| // via .draw(with:options:context:) | +| // 3. draw accessory glyphs via NSImage.draw | +| | +| override func prepareForReuse() | +| cachedAttrString = nil | +| | +| override func mouseDown(with:) | +| // hit-test accessory rects directly | +| | +| No subviews. No NSTextField. No NSButton. | +| No CellFocusOverlay. No backgroundView. | +| | +| Threading: @MainActor | +| Apple API: AppKit/NSView.h, Foundation/NSStringDrawing.h| +| File: TablePro/Views/Results/Cells/DataGridCellView.swift| ++----------------------------------------------------------+ + ++----------------------------------------------------------+ +| Row view | +| | +| final class DataGridRowView: NSTableRowView | +| override func drawBackground(in:) | +| // draw change-state tint here, once per row | +| override func drawSelection(in:) | +| // honor isEmphasized for inactive-window dim | +| | +| Apple API: AppKit/NSTableRowView.h | +| File: TablePro/Views/Results/DataGridRowView.swift | ++----------------------------------------------------------+ + ++----------------------------------------------------------+ +| Header | +| | +| Stock NSTableHeaderView. Stock NSTableHeaderCell. | +| Each NSTableColumn has sortDescriptorPrototype set. | +| Multi-column sort priority badges drawn by AppKit | +| (drawSortIndicator: handles priority arg). | +| | +| Sort dispatch via DataGridDataSource.tableView( | +| _:sortDescriptorsDidChange:). | +| | +| Apple API: AppKit/NSTableHeaderView.h, AppKit/NSTableHeaderCell.h| ++----------------------------------------------------------+ + ++----------------------------------------------------------+ +| Field editor | +| | +| EditorWindow.windowWillReturnFieldEditor(_:to:) | +| -> if cell.usesMultilineFieldEditor: | +| return MultilineFieldEditor.shared | +| else: | +| return nil // AppKit default | +| | +| final class MultilineFieldEditor: NSTextView | +| static let shared = MultilineFieldEditor(frame:.zero)| +| // isFieldEditor = true, allowsUndo = true | +| | +| Apple API: AppKit/NSWindow.h, AppKit/NSTextView.h | ++----------------------------------------------------------+ + ++----------------------------------------------------------+ +| Focus overlay (single, on tableView) | +| | +| final class FocusOverlayView: NSView | +| // pinned to one cell's rect via | +| // tableView.frameOfCell(atColumn:row:) | +| // toggled hidden on focus change | +| override func draw(_:) | +| // draw rounded border via NSBezierPath | +| | +| Apple API: AppKit/NSTableView.h frameOfCell(atColumn:row:),| +| AppKit/NSBezierPath.h | ++----------------------------------------------------------+ + ++----------------------------------------------------------+ +| Visual state | +| | +| @MainActor final class RowVisualIndex | +| var deleted: Set | +| var inserted: Set | +| var modifiedColumnsByRow: [Int: Set] | +| func apply(_ change: ChangeManagerDelta) | +| func state(for row: Int) -> RowVisualState | +| | +| Replaces rowVisualStateCache rebuild-from-scratch. | +| Apple API: standard Swift | +| File: TablePro/Views/Results/RowVisualIndex.swift | ++----------------------------------------------------------+ +``` + +Layer ownership rules: + +- The plugin returns `AsyncThrowingStream`. It never owns Swift state past the stream lifetime. +- `StreamingDataGridStore` owns the row buffer, the display cache, the index map, and the change stream. Nothing else holds these directly. +- `CellDisplayWarmer` is invoked from the store actor; it holds no state across calls. +- `DataGridViewController` is `@MainActor`. It binds to the store's `changes: AsyncStream` once on `viewWillAppear`, drives `tableView.beginUpdates()` / `insertRows(at:withAnimation:)` / `endUpdates()` from the stream, and cancels the binding `Task` on `viewDidDisappear`. +- The SwiftUI `DataGridView` is `NSViewControllerRepresentable`. `updateNSViewController` builds one `Equatable` `Snapshot` and only calls `controller.apply(snapshot)` when the snapshot differs from `lastApplied`. +- Cells own no state past `prepareForReuse`. The cached `NSAttributedString` is invalidated there. +- Row views own change-state tinting and selection rendering. Cells never read `backgroundStyle` to make those decisions. + +Apple API references (frameworks and headers): +- `AppKit/NSView.h` (`wantsLayer`, `layerContentsRedrawPolicy`, `canDrawSubviewsIntoLayer`, `action(for:forKey:)`, `noteFocusRingMaskChanged`, `draw(_:)`, `menu`) +- `AppKit/NSTableView.h` (`autosaveName`, `autosaveTableColumns`, `frameOfCell(atColumn:row:)`, `editColumn:row:with:select:`, `intercellSpacing`, `usesAutomaticRowHeights`, `gridStyleMask`, `draggingDestinationFeedbackStyle`, `selectionIndexesForProposedSelection`, `typeSelectStringFor:row:`, `reloadData(forRowIndexes:columnIndexes:)`, `insertRows(at:withAnimation:)`, `removeRows(at:withAnimation:)`) +- `AppKit/NSTableColumn.h` (`sortDescriptorPrototype`, `isHidden`, `resizingMask`) +- `AppKit/NSTableHeaderView.h` (stock cursor handling) +- `AppKit/NSTableHeaderCell.h` (`drawSortIndicator(withFrame:in:ascending:priority:)`) +- `AppKit/NSTableRowView.h` (`drawBackground(in:)`, `drawSelection(in:)`, `isEmphasized`, `interiorBackgroundStyle`) +- `AppKit/NSWindow.h` (`windowWillReturnFieldEditor:to:`, `tabbingMode`, `isRestorable`, `representedURL`, `subtitle`) +- `AppKit/NSWindowRestoration.h` (`encodeRestorableState(with:)`, `restoreState(with:)`) +- `AppKit/NSTextView.h` (`isFieldEditor`) +- `AppKit/NSPanel.h` (`nonactivatingPanel` for QuickSwitcher only) +- `AppKit/NSPopover.h` (FK and Enum content rebuild) +- `AppKit/NSAccessibilityProtocols.h` (`setAccessibilityRowIndexRange`, `setAccessibilityColumnIndexRange`) +- `AppKit/NSBezierPath.h` (focus overlay drawing) +- `AppKit/NSGraphicsContext.h` (`NSColor.setFill`, `NSRect.fill`) +- `Foundation/NSCache.h` (`countLimit`, `totalCostLimit`, `evictsObjectsWithDiscardedContent`) +- `Foundation/NSMapTable.h` (`weakToWeakObjects`) +- `Foundation/NSStringDrawing.h` (`NSAttributedString.draw(with:options:context:)`) +- `QuartzCore/CAAction.h` (`NSNull` as no-op action) +- Swift Concurrency: SE-0306 (actors), SE-0314 (`AsyncStream`, `AsyncThrowingStream`), SE-0329 (`Clock`, `Duration`, `Task.sleep(for:)`), SE-0395 (`@Observable`) +- SwiftUI: `NSViewControllerRepresentable` + +--- + +## 5. Refactor sequence + +Each stage is one PR. Each PR compiles, passes tests, ships to users without regressions. Per CLAUDE.md "Atomic API changes" rule, every stage that renames or changes a signature updates every caller and every test in the same commit. No stage leaves the codebase mid-refactor between commits. + +The sequence is ordered by dependency. Earlier stages set up the contracts later stages need. No stage may be reordered without re-validating that the codebase still compiles and ships. + +### Stage 1 - Cell collapse: one NSView, one layer, redraw policy + +Goal: replace `DataGridBaseCellView` plus seven empty subclasses with a single `DataGridCellView` that has one layer with `.onSetNeedsDisplay` redraw policy, removes `CellFocusOverlay`, removes the per-cell `backgroundView`, removes the per-cell `CATransaction`, and draws text and accessories directly via `NSAttributedString.draw(with:options:context:)` and `NSImage.draw(in:)`. Add `DataGridRowView: NSTableRowView` for change-state tinting and selection rendering. Add a single `FocusOverlayView` owned by the table view. + +Touched files: +- Added: `Views/Results/Cells/DataGridCellView.swift`, `Views/Results/DataGridRowView.swift`, `Views/Results/FocusOverlayView.swift` +- Modified: `Views/Results/Cells/DataGridCellRegistry.swift` (collapse the seven kind-specific subclasses to a single `DataGridCellView` plus a `DataGridCellKind` flag), `Views/Results/Cells/DataGridCellAccessoryDelegate.swift`, `Views/Results/TableRowViewWithMenu.swift` (renamed/replaced by `DataGridRowView`), `Views/Results/KeyHandlingTableView.swift` (owns `focusOverlay`), `Views/Results/DataGridView.swift` (use `DataGridRowView` from `tableView(_:rowViewForRow:)`) +- Deleted: `Views/Results/Cells/DataGridBaseCellView.swift`, `Views/Results/Cells/CellFocusOverlay.swift`, `Views/Results/Cells/DataGridBlobCellView.swift`, `Views/Results/Cells/DataGridBooleanCellView.swift`, `Views/Results/Cells/DataGridDateCellView.swift`, `Views/Results/Cells/DataGridDropdownCellView.swift`, `Views/Results/Cells/DataGridJsonCellView.swift`, `Views/Results/Cells/DataGridChevronCellView.swift`, `Views/Results/Cells/DataGridForeignKeyCellView.swift`, `Views/Results/Cells/DataGridTextCellView.swift`, `Views/Results/Cells/AccessoryButtons.swift` + +API surface delta: +- `DataGridBaseCellView` symbol gone. Coordinator references update to `DataGridCellView`. +- `DataGridCellKind` becomes a simple enum (`text`, `foreignKey`, `dropdown`, `boolean`, `date`, `json`, `blob`) used by `DataGridCellView` to decide accessory glyphs at draw time. +- No PluginKit ABI bump. + +New tests required: +- `TableProTests/DataGridCellViewTests.swift` - drawing test that pre-renders an `NSImage` of a configured cell and asserts pixel-level equality vs a baseline. Use `bitmapImageRep(forCachingDisplayIn:)`/`cacheDisplay(in:to:)`. +- `TableProTests/DataGridRowViewTests.swift` - `drawBackground(in:)` produces the expected change-state fill. +- `TableProTests/FocusOverlayViewTests.swift` - overlay positions itself correctly via `tableView.frameOfCell(atColumn:row:)`. + +Risk and rollback: high test surface (every cell kind), but the change is mechanical. If a regression appears, revert the deletion and the registry change as one PR. + +Why this ordering: the cell layer is the largest single contributor to scroll lag (R1/R2/R3/R4/R5 all converge here). Stages 2 onwards depend on the cell being a single `NSView` for the snapshot diff (stage 9) and the data path (stages 3-7) to work without re-introducing per-cell layer cost. + +--- + +### Stage 2 - Display cache as NSCache + index-aligned slots + +Goal: replace the unbounded `[RowID: [String?]]` `displayCache` with `NSCache` (or `NSCache`) keyed by display index. Pre-allocate `ContiguousArray` slots once per row instead of `Array(repeating:)`-and-append on every cache miss. Add `indexByID: [RowID: Int]` to `TableRows` for `O(1)` reverse lookup. Replace `pruneDisplayCacheToAliveIDs()` filter-then-allocate with in-place removal. + +Touched files: +- Modified: `Views/Results/DataGridCoordinator.swift` (cache type swap, `indexByID` use), `Models/Query/TableRows.swift` (add `indexByID`), `Models/Query/Row.swift` (use `ContiguousArray`) +- Added: `Core/DataGrid/RowDisplayBox.swift` (the boxed `NSArray`-compatible cache value) + +API surface delta: +- `TableRows.index(of:)` becomes `O(1)` instead of `O(n)`. Same signature. +- `Row.values: [String?]` becomes `Row.values: ContiguousArray`. Every caller updates in the same commit. +- `displayCache` field type changes; the property is `private` so callers do not see the change. + +New tests required: +- `TableProTests/TableRowsTests.swift` - `indexByID` stays in lockstep with `rows` across `appendInsertedRow`, `insertInsertedRow`, `appendPage`, `removeIndices`, `replace(rows:)`. +- `TableProTests/DisplayCacheBoundedTests.swift` - `NSCache.totalCostLimit` enforces an RAM ceiling under sustained insertion. +- `TableProTests/DisplayCacheCorrectnessTests.swift` - cache hit returns the same string the formatter would produce. + +Risk and rollback: medium. The `Row.values` type change touches every caller; the compiler enforces atomicity. Revert is a single commit revert. + +Why this ordering: the rest of the data-path stages assume `O(1)` `RowID → Int` and a bounded cache. Without those, stages 3-5 cannot demonstrate the latency wins. + +--- + +### Stage 3 - RowVisualIndex incremental updates + +Goal: replace `rebuildVisualStateCache()` (rebuilds `[Int: RowVisualState]` from scratch on every change) with `RowVisualIndex` that applies each `ChangeManagerDelta` incrementally. Drop the `currentVersion != lastVisualStateCacheVersion` short-circuit; it never short-circuited in practice because every edit bumps the version. + +Touched files: +- Added: `Views/Results/RowVisualIndex.swift` +- Modified: `Views/Results/DataGridCoordinator.swift` (delete `rebuildVisualStateCache()` and `rowVisualStateCache`, route every delta through `visualIndex.apply(_:)`) + +API surface delta: +- `TableViewCoordinator.rebuildVisualStateCache()` deleted. Callers (`applyInsertedRows`, `applyRemovedRows`, `applyDelta`, `updateNSView`) call `visualIndex.apply(delta)` instead. + +New tests required: +- `TableProTests/RowVisualIndexTests.swift` - covers each delta kind (`cellEdited`, `rowDeleted`, `rowInserted`, `changesCommitted`, `changesDiscarded`). + +Risk and rollback: low. Internal implementation change, no external API delta. + +Why this ordering: stages 4 and beyond move work off-main; the visual index needs to be `@MainActor` only (it drives undo/redo and selection chrome). Settling its shape now lets the store actor in stage 4 ignore visual state entirely. + +--- + +### Stage 4 - actor StreamingDataGridStore behind protocol + +Goal: introduce `protocol DataGridStore: Sendable` and `actor StreamingDataGridStore`. The store owns the row buffer, the display cache (now living on the actor), and the change stream. The coordinator holds a `let store: any DataGridStore`. The store is initialized but does not yet stream; it loads via the existing non-streaming `executeUserQuery(query:rowCap:parameters:)` and yields one `.fullReplace` change to the coordinator. This lets us validate the actor isolation and the change-stream loop without bumping the plugin ABI. + +Touched files: +- Added: `Core/DataGrid/DataGridStore.swift` (protocol), `Core/DataGrid/StreamingDataGridStore.swift` (actor implementation), `Core/DataGrid/DataGridChange.swift` (enum), `Core/DataGrid/DisplaySnapshot.swift` (Sendable struct), `Core/DataGrid/CellDisplay.swift`, `Core/DataGrid/CellState.swift` +- Modified: `Views/Results/DataGridCoordinator.swift` (subscribe to `store.changes` once on attach, replace the property fan-out with a `Snapshot` consumed via `apply(snapshot:)`), `Views/Main/MainContentCoordinator.swift` (creates `StreamingDataGridStore` instead of mutating `TableRows` directly) + +API surface delta: +- `tableRowsProvider: () -> TableRows` and `tableRowsMutator: ((inout TableRows) -> Void) -> Void` closures gone from `DataGridView`. Replaced by `let store: any DataGridStore`. +- `Delta` enum (in `TableRowsController`) replaced by `DataGridChange`. +- `CellDisplayFormatter`, `DateFormattingService`, `BlobFormattingService` change from `@MainActor` to `nonisolated`. All callers updated. + +New tests required: +- `TableProTests/StreamingDataGridStoreTests.swift` - actor init, `cellDisplay(at:column:)` returns expected formatted strings, `changes` stream emits in order, cancellation tears down cleanly. +- `TableProTests/CellDisplayFormatterNonisolatedTests.swift` - formatter produces identical output called from any context. + +Risk and rollback: high. This is the largest single change. Mitigation: gate the new path behind a feature flag for one release if needed (per CLAUDE.md "no feature flags" rule, only if a regression is found in beta - and the flag is removed before final). + +Why this ordering: stages 5-7 build on the actor. Without the store as the single owner of row state, off-main formatting and streaming have no coherent home. + +--- + +### Stage 5 - Off-main CellDisplayWarmer + +Goal: introduce `actor CellDisplayWarmer` invoked from `StreamingDataGridStore`. Move the formatting work that today runs in `preWarmDisplayCache(upTo:)` synchronously inside `updateNSView` to the warmer. The store calls `await warmer.warm(chunk:columnTypes:displayFormats:previewLength:)` and stores the result in its `NSCache`. The coordinator only ever reads the warmed cache synchronously from `tableView(_:viewFor:row:)`. Settings changes (date format, null display, smart value detection) trigger a re-warm of the visible window via the warmer; never on main. + +Touched files: +- Added: `Core/DataGrid/CellDisplayWarmer.swift` +- Modified: `Core/DataGrid/StreamingDataGridStore.swift` (own and call the warmer), `Views/Results/DataGridCoordinator.swift` (delete `preWarmDisplayCache(upTo:)`, the settings-change handler now `await store.reformatVisibleWindow(...)`) +- Deleted: `preWarmDisplayCache(upTo:)` method on the coordinator (was at `DataGridCoordinator.swift:305-327`) + +API surface delta: +- `TableViewCoordinator.preWarmDisplayCache(upTo:)` deleted. `DataGridView.updateNSView` no longer calls it. +- New `previewLength: Int` parameter on `cellDisplay`. Grid passes 300; export passes `Int.max`. + +New tests required: +- `TableProTests/CellDisplayWarmerTests.swift` - warm produces correct strings for date, blob, JSON, NULL, large strings (truncation at `previewLength`). +- `TableProTests/DataGridStoreSettingsChangeTests.swift` - settings change triggers re-warm; no main-thread blocking occurs. + +Risk and rollback: medium. Single-file revert. + +Why this ordering: stage 6 (streaming plugin) needs the warmer to consume chunks as they arrive. Without off-main formatting, streaming would just move the bottleneck. + +--- + +### Stage 6 - Plugin ABI bump: streaming envelope for grid path + +Goal: `PluginDatabaseDriver` gains `executeStreamingQuery(_:rowCap:parameters:)` returning `AsyncThrowingStream`. Default implementation wraps `execute(query:)` in chunks of 1,000. Built-in plugins (PostgreSQL, MySQL, ClickHouse) override with native streaming via wire-protocol-level cursors (PostgreSQL `PQgetRow`, MySQL `mysql_fetch_row`, ClickHouse native row decoder). `StreamingDataGridStore.start(...)` consumes the stream and yields `DataGridChange.rowsAppended` per chunk. Bump `currentPluginKitVersion` and every plugin's `TableProPluginKitVersion`. **This is a hard plugin compatibility break.** + +Touched files: +- Modified: `Plugins/TableProPluginKit/Sources/TableProPluginKit/PluginDatabaseDriver.swift` (add the new method with default impl), `Plugins/TableProPluginKit/Sources/TableProPluginKit/PluginQueryResult.swift` (add `metadata` case to `PluginStreamElement` for executionTime/isTruncated/statusMessage flow), `Core/Plugins/PluginManager.swift` (bump `currentPluginKitVersion`), every plugin's `Info.plist` (`TableProPluginKitVersion`), every plugin's main class (override `executeStreamingQuery` for native streaming where available) +- Modified: `Core/Plugins/PluginDriverAdapter.swift` (bridge new method to `DatabaseDriver`) +- Modified: `Core/DataGrid/StreamingDataGridStore.swift` (consume the stream) + +API surface delta: +- `PluginDatabaseDriver` adds a new required method (with default implementation, so old plugins compile against new headers, but ABI mismatch on load triggers `EXC_BAD_INSTRUCTION` per CLAUDE.md). Stale user-installed plugins refuse to load. Distribution must update every plugin in lockstep. +- `currentPluginKitVersion` bumps by 1. +- `PluginStreamElement.metadata(executionTime:rowsAffected:isTruncated:statusMessage:)` added. + +New tests required: +- `TableProTests/PluginStreamingTests.swift` - default-impl bridge produces a stream that yields the same data as `execute(query:)`. +- `TableProTests/PluginStreamingPostgresTests.swift` (etc. for each native-streaming plugin) - large queries stream incrementally; first chunk arrives before query finishes. +- `TableProTests/PluginKitVersionMismatchTests.swift` - confirms `EXC_BAD_INSTRUCTION` is caught at load time when plugin's `TableProPluginKitVersion` does not match `currentPluginKitVersion`. + +Risk and rollback: very high. This is a plugin ABI break. Per CLAUDE.md: "stale user-installed plugins with mismatched versions crash on load with `EXC_BAD_INSTRUCTION` (not catchable in Swift)." Mitigation: ship every plugin update simultaneously with the app update; never partial-roll. Revert is a single commit revert plus a coordinated plugin re-release. + +Why this ordering: streaming is the prerequisite for the first-paint-under-500ms target on 1M-row queries. It must follow the store actor (stage 4) and the warmer (stage 5) so the chunks have somewhere coherent to land. It must precede the AsyncStream-driven coordinator binding (stage 8) so the change stream is the genuine driver. + +--- + +### Stage 7 - Debounced AsyncStream + structured-concurrency cleanup + +Goal: drop every redundant `Task { @MainActor in ... }` hop that already runs on main (CellOverlayEditor `boundsDidChange` observers - these go away in stage 11; `DataGridView+Editing.swift:202-205, 224-227` selectors; `DataGridCoordinator.swift:186-194` teardown). Replace `DispatchQueue.main.asyncAfter` with cancellable `Task.sleep(for:)` in `ResultsJsonView`, `JSONSyntaxTextView`, `HexEditorContentView`. Move `JsonRowConverter.generateJson` and `JSONTreeParser.parse` off main via `Task.detached(priority: .userInitiated)` with a generation token. Add a single coordinator-side debounce of 100ms on the `store.changes` stream. Replace the three `DataGridCoordinator` Combine cancellables (settings/theme/teardown) with one `eventTask: Task?` consuming `AppEvents.shared.dataGridEvents: AsyncStream`. + +Touched files: +- Modified: `Views/Results/DataGridCoordinator.swift` (single event task replaces three cancellables, ensure `releaseData()` cancels it before nilling `delegate`), `Views/Results/Extensions/DataGridView+Editing.swift` (drop two unstructured Tasks), `Views/Results/ResultsJsonView.swift` (off-main JSON parse with token, cancellable cooldown), `Views/Results/JSONSyntaxTextView.swift`, `Views/Results/HexEditorContentView.swift`, `Core/Events/AppEvents.swift` (add `dataGridEvents` stream alongside the Combine subjects) +- Added: `Core/Concurrency/CooldownTimer.swift` (shared cancellable Task wrapper) + +API surface delta: +- `AppEvents.shared.dataGridEvents: AsyncStream` is new. The Combine `PassthroughSubject` properties stay for backward compatibility. +- `CooldownTimer` is a new helper. Internal use only. +- All settings-change / theme-change handlers move to the unified event loop. + +New tests required: +- `TableProTests/DataGridCoordinatorEventLoopTests.swift` - single event Task receives every kind of `DataGridEvent`; cancellation on `releaseData()` cleanly tears down without leaking observers. +- `TableProTests/CooldownTimerTests.swift` - `schedule(after:_:)` cancels prior, fires once. +- `TableProTests/ResultsJsonViewOffMainTests.swift` - selection change does not block main thread for >16ms with 5K-row selections. + +Risk and rollback: medium. The unstructured-Task cleanup is mechanical; the AppEvents AsyncStream addition is additive. Revert is a single commit revert. + +Why this ordering: stages 4-6 introduced the store and the streaming plugin; stage 7 makes the coordinator's binding to those pure structured concurrency. Without this, the structured-concurrency story is half-done. + +--- + +### Stage 8 - NSViewControllerRepresentable + Snapshot diff + +Goal: replace `struct DataGridView: NSViewRepresentable` with `struct DataGridView: NSViewControllerRepresentable`. The new `DataGridViewController: NSViewController` owns the scroll view, the table view, the column pool, the data source, the delegate, the field editor controller, and the visual index. `updateNSViewController` builds one `Equatable` `Snapshot` and only calls `controller.apply(snapshot)` when the snapshot differs from `coordinator.lastApplied`. `dataGridAttach(...)` fires only when `delegate` identity changes. The duplicate `coordinator.updateCache()` call goes away. + +Touched files: +- Added: `Views/Results/DataGridViewController.swift`, `Views/Results/DataGridSnapshot.swift` +- Modified: `Views/Results/DataGridView.swift` (becomes thin `NSViewControllerRepresentable`), `Views/Results/DataGridCoordinator.swift` (the existing class loses the AppKit ownership - `tableView`, scrollview lifetimes - to the view controller, becomes a context object) + +API surface delta: +- `DataGridView` becomes `NSViewControllerRepresentable`. `makeNSView`/`updateNSView`/`dismantleNSView` replaced by `makeNSViewController`/`updateNSViewController`/`dismantleNSViewController`. +- `Snapshot` is a new `Equatable` struct. +- The 25 properties on the coordinator that today are reassigned per `updateNSView` move into `Snapshot`. + +New tests required: +- `TableProTests/DataGridViewControllerTests.swift` - view controller lifecycle (`viewDidLoad`, `viewWillAppear`, `viewDidDisappear`) wires up cleanly; `apply(snapshot:)` only modifies AppKit when the snapshot changes. +- `TableProTests/DataGridSnapshotEquatableTests.swift` - `Snapshot` correctly identifies meaningful changes (rows reordered, columns hidden) and ignores no-op rebindings. + +Risk and rollback: medium. The change is mechanical but touches the SwiftUI/AppKit seam, which is where misuse historically caused leaks. Revert is one PR. + +Why this ordering: stages 1-7 reduced the work the coordinator does per `updateNSView`. Stage 8 makes that work conditional via the diff. Without 1-7, the diff would gate work that is itself slow. + +--- + +### Stage 9 - Native AppKit table behaviors: autosave, sort, field editor, type-select, drag-drop, menu + +Goal: drop ~700 lines of custom AppKit reimplementation. Specifically: +- Set `tableView.autosaveName` and `tableView.autosaveTableColumns = true`. Delete `FileColumnLayoutPersister`, `ColumnLayoutState`, `captureColumnLayout`, `persistColumnLayoutToStorage`, `savedColumnLayout(binding:)`, the `onColumnLayoutDidChange` callback, the `@Binding columnLayout`, and the `DataGridColumnPool.reconcile(savedLayout:)` parameter. Add a one-time `UserDefaults` migration from the legacy JSON file to the AppKit-native key (`NSTableView Columns `). +- Set every `NSTableColumn.sortDescriptorPrototype = NSSortDescriptor(key: name, ascending: true)`. Implement `tableView(_:sortDescriptorsDidChange:)` on `DataGridDataSource`. Restore stock `NSTableHeaderView` and `NSTableHeaderCell`. Delete `SortableHeaderView.swift` (288 lines), `SortableHeaderCell.swift` (182 lines), `HeaderSortCycle` enum, `HeaderSortTransition`, `currentSortState` mirror. +- Add `usesMultilineFieldEditor: Bool` to `CellTextField`. Make `EditorWindow` implement `windowWillReturnFieldEditor(_:to:)` returning `MultilineFieldEditor.shared` (a long-lived `NSTextView` with `isFieldEditor = true`). Delete `CellOverlayEditor.swift` (243 lines), `CellOverlayPanel`, `OverlayTextView`, `showOverlayEditor`, `commitOverlayEdit`, `handleOverlayTabNavigation`, `InlineEditEligibility.needsOverlayEditor`, the `KeyHandlingTableView.insertNewline` overlay branch. +- Add `tableView(_:typeSelectStringFor:row:)` for free incremental search on the first non-row-number column. +- Replace `KeyHandlingTableView.menu(for:)` manual routing with `tableView.menu = makeEmptySpaceMenu()` plus `rowView.menu = makeRowMenu(for:)` set in `tableView(_:rowViewForRow:)`. +- Set `tableView.intercellSpacing = NSSize(width: 0, height: 0)` and `gridStyleMask = [.solidVerticalGridLineMask, .solidHorizontalGridLineMask]` (Gridex parity). +- Set `tableView.usesAutomaticRowHeights = false` explicitly. +- Switch `undoInsertRow(at:)` from `tableView.reloadData()` to `tableView.removeRows(at: IndexSet(integer: index), withAnimation: .slideUp)`. +- Set `tableView.draggingDestinationFeedbackStyle = .gap` once in `viewDidLoad`, not inside the conditional drop-types block. + +Touched files: +- Modified: `Views/Results/DataGridViewController.swift` (autosave name, type-select, menu wiring, intercell spacing, usesAutomaticRowHeights), `Views/Results/DataGridDataSource.swift` (split out in stage 8; `sortDescriptorsDidChange`), `Views/Results/DataGridFieldEditorController.swift` (split out in stage 8), `Views/Results/DataGridDelegate.swift`, `Views/Results/DataGridColumnPool.swift` (drop `savedLayout`), `Views/Results/CellTextField.swift` (add `usesMultilineFieldEditor`, the multi-line `MultilineFieldEditor.shared`), `Views/Results/Extensions/DataGridView+RowActions.swift` (`undoInsertRow` uses `removeRows`), `Views/Results/Extensions/DataGridView+Editing.swift` (drop `showOverlayEditor` and friends), `Core/Services/Infrastructure/EditorWindow.swift` or `MainContentView+Setup.swift` (`windowWillReturnFieldEditor`), `Views/Results/KeyHandlingTableView.swift` (drop `menu(for:)` override and `insertNewline` overlay branch) +- Added: `Views/Results/MultilineFieldEditor.swift` (one shared instance), `Core/Storage/LegacyColumnLayoutMigration.swift` (one-time migration helper) +- Deleted: `Views/Results/SortableHeaderView.swift`, `Views/Results/SortableHeaderCell.swift`, `Views/Results/CellOverlayEditor.swift`, `Core/Storage/FileColumnLayoutPersister.swift`, `Models/UI/ColumnLayoutState.swift` (kept only as a transient migration source) + +API surface delta: +- `TableViewCoordinator.savedColumnLayout`, `captureColumnLayout`, `persistColumnLayoutToStorage`, `currentSortState`, `onColumnLayoutDidChange` deleted. +- `DataGridView.syncSortDescriptors` becomes a one-line `tableView.sortDescriptors = newDescriptors`. +- `KeyHandlingTableView.menu(for:)` override deleted. +- `InlineEditEligibility.needsOverlayEditor` case deleted. +- `CellTextField` gains `usesMultilineFieldEditor: Bool`. + +New tests required: +- `TableProTests/AutosaveColumnLayoutTests.swift` - column resize/reorder/hide is persisted across `dismantleNSViewController`/`makeNSViewController` round-trip via UserDefaults; legacy JSON file is migrated once and deleted. +- `TableProTests/SortDescriptorsTests.swift` - single-column sort cycles asc → desc → cleared (third click clears via post-filter in `sortDescriptorsDidChange`); shift-click appends; multi-column priority badges render correctly via stock `NSTableHeaderCell.drawSortIndicator`. +- `TableProTests/FieldEditorTests.swift` - multi-line cells get the `MultilineFieldEditor`; single-line cells get the default; Return commits, Esc cancels, Tab advances, Option-Return inserts newline. +- `TableProTests/TypeSelectTests.swift` - typing prefix on the table view scrolls to and selects the matching row. +- `TableProTests/UndoInsertRowAnimatedTests.swift` - `undoInsertRow` uses animated removal, not full reload. + +Risk and rollback: medium. Five files deleted, four added, large API contract change. Each sub-bullet is technically reversible independently, but the autosave migration is a UserDefaults write that should not be reverted blindly (the legacy JSON file is deleted post-migration). + +Why this ordering: stages 1-8 stabilized the cell, the data path, the actor, the streaming plugin, the structured concurrency, and the snapshot diff. Stage 9 deletes the custom code that those stages obviated. It is the largest deletion in the rewrite (~700 lines net). + +--- + +### Stage 10 - SwiftUI interop cleanups: Snapshot boundary, single-source searchText, popover content rebuild + +Goal: replace `PopoverPresenter`'s "NSPopover → NSHostingController → SwiftUI body containing NSViewRepresentable" triple-nest (S3) with two helpers - `AppKitPopover.show(controller:)` for AppKit-content popovers and direct SwiftUI `.popover(isPresented:)` for SwiftUI-content popovers. Migrate `EnumPopoverContentView` and `ForeignKeyPopoverContentView` to `NSPopover` containing an `NSViewController` whose root is `NSStackView { NSSearchField, NSScrollView { NSTableView } }`. Migrate `FilterValueTextField`'s suggestion dropdown to the same shape. Drop `SidebarViewModel.searchText` (S6); keep the value only in `SharedSidebarState`; read directly via `@Bindable var sidebarState`. Replace `FilterValueTextField.SuggestionState` `ObservableObject` with `@Observable` (S2) and switch its `id: \.offset` to `id: \.self`. + +Touched files: +- Added: `Views/Components/AppKitPopover.swift` (the `show(controller:)` helper), `Views/Results/EnumPopoverViewController.swift` (AppKit native), `Views/Results/ForeignKeyPopoverViewController.swift` (AppKit native), `Views/Filter/SuggestionDropdownViewController.swift` (AppKit native) +- Modified: `Views/Filter/FilterValueTextField.swift` (use `AppKitPopover.show(controller:)` and the new `SuggestionDropdownViewController`; drop the `NSEvent.addLocalMonitorForEvents` shim - `NSPopover` with the AppKit content routes Escape/Return through the responder chain natively), `ViewModels/SidebarViewModel.swift` (drop `searchText`), `Views/Sidebar/SidebarView.swift` (read `sidebarState.searchText` directly), `Views/Results/DataGridView+Popovers.swift` (callers use the AppKit popover for FK/Enum) +- Deleted: `Views/Components/PopoverPresenter.swift`, `Views/Results/EnumPopoverContentView.swift`, `Views/Results/ForeignKeyPopoverContentView.swift`, `Views/Filter/SuggestionDropdownView` (the SwiftUI inner view inside `FilterValueTextField.swift`) + +API surface delta: +- `PopoverPresenter` deleted. Two new helpers replace it. +- `FilterValueTextField.SuggestionState` becomes an `@Observable` class. +- `SidebarViewModel.searchText` deleted; bindings to it from views replaced with bindings to `SharedSidebarState.searchText`. + +New tests required: +- `TableProTests/AppKitPopoverTests.swift` - popover lifecycle, key event routing, dismiss on outside click. +- `TableProTests/EnumPopoverViewControllerTests.swift` - selection, search filter, Return commits, Esc cancels. +- `TableProTests/ForeignKeyPopoverViewControllerTests.swift` - async fetch cancellation on dismiss; large lists scroll smoothly. +- `TableProTests/SidebarSearchTextSingleSourceTests.swift` - writes to one source flow to all readers without sync drift. + +Risk and rollback: medium. Several SwiftUI views become AppKit view controllers. Each is locally testable. Revert is per-component. + +Why this ordering: stages 1-9 settled the data grid itself. Stage 10 is the surrounding chrome that depends on the same SwiftUI/AppKit boundary rules established in stage 8. Sidebar tree (Redis `NSOutlineView` migration, SwiftUI-interop S5) is **not** in this stage - it is independent and will be tracked separately. + +--- + +### Stage 11 - HIG corrections: QuickSwitcher NSPanel, color assets, window restoration, Spotlight intents, services, toolbar identifier + +Goal: address the remaining HIG findings that do not depend on the data grid rewrite. +- Replace QuickSwitcher's `.sheet(item:)` presentation with `NSPanel` styled `[.nonactivatingPanel, .titled, .fullSizeContentView]`, `becomesKeyOnlyIfNeeded = true`, `hidesOnDeactivate = true`, `level = .floating`. Drop the `.onKeyPress(.return)` SwiftUI shim - `NSPanel` routes Return through `cancelOperation:`/`insertNewline:` via the responder chain. +- Move every base UI color (`windowBackground`, `controlBackground`, `selectionBackground`, `separator`, `label`, etc.) into `Assets.xcassets` color sets with four variants (`Any Appearance`, `Dark`, `Any Appearance / High Contrast`, `Dark / High Contrast`). Custom themes substitute named asset entries; hex literals only allowed for syntax-highlighter colors where dark/light variants do not apply. Read `\.accessibilityReduceTransparency` and `\.colorSchemeContrast` at every site that uses `.ultraThinMaterial`. +- Set `window.isRestorable = true`. Implement `NSWindowRestoration` on a `MainSplitViewController` subclass (or a small responder subclass). Encode `connectionId`, `selectedTabId`, sidebar split position, scroll offset, selected row indexes, applied filter, schema. Decode in `restoreWindow(withIdentifier:state:completionHandler:)`. +- Add `OpenConnectionIntent` (`AppIntent` conforming to `OpenIntent`), `IndexedEntity` for connections, `AppShortcutsProvider` static list. Backfill `NSUserActivity.isEligibleForSearch = true` and `contentAttributeSet` at `TabWindowController.swift:198-233`. +- Implement `NSServicesMenuRequestor` on the SQL editor's `NSTextView` and on the data grid table view. +- Replace `NSToolbar(identifier: "com.TablePro.main.toolbar.\(UUID().uuidString)")` with stable `"com.TablePro.main.toolbar"`. Set `NSToolbar.centeredItemIdentifiers` for the principal item. +- Drop `inspectorHeader` from `UnifiedRightPanelView` (`NSSplitViewItem.behavior = .inspector` already provides the chrome). Move the segmented tab picker to a toolbar item. + +Touched files: +- Modified: `Views/QuickSwitcher/QuickSwitcherView.swift` and `Views/QuickSwitcher/QuickSwitcherPanel.swift` (new `NSPanel`-hosted controller), `Views/Main/MainContentView.swift` (drop the `.sheet(item:)` for QuickSwitcher), `TablePro/Resources/Assets.xcassets/` (add color sets), `Theme/ResolvedThemeColors.swift` (resolve from named assets), `Core/Services/Infrastructure/TabWindowController.swift` (`isRestorable = true`, `NSUserActivity.isEligibleForSearch`), `Views/Infrastructure/WindowChromeConfigurator.swift` (default `isRestorable = true`), `Views/Main/MainSplitViewController.swift` (NSWindowRestoration), `Views/Toolbar/MainWindowToolbar.swift` (stable identifier, `centeredItemIdentifiers`), `Views/RightSidebar/UnifiedRightPanelView.swift` (drop inspectorHeader), `TableProApp.swift` (register `AppShortcutsProvider`) +- Added: `Core/AppIntents/OpenConnectionIntent.swift`, `Core/AppIntents/ConnectionEntity.swift`, `Core/AppIntents/AppShortcutsProvider.swift`, `Core/Restoration/MainWindowRestoration.swift` + +API surface delta: +- QuickSwitcher presentation moves from SwiftUI `.sheet` to AppKit `NSPanel`. The activate-switch shortcut handler stays the same key chord. +- New AppIntents are additive. +- `NSToolbar` identifier changes from per-launch UUID to stable. Per-window state is autosaved by `NSToolbar.autosavesConfiguration = true` + `NSToolbar.configuration` (macOS 13+). + +New tests required: +- `TableProTests/QuickSwitcherPanelTests.swift` - panel does not steal key from the previous window, dismisses on outside click and Esc. +- `TableProTests/WindowRestorationTests.swift` - encode/decode round-trip preserves connection, tab, sidebar, scroll, selection, filter. +- `TableProTests/AppIntentsTests.swift` - `OpenConnectionIntent` succeeds for a registered connection, fails gracefully for unknown. + +Risk and rollback: medium. UserDefaults migration for color theme overrides, restoration coder is on-disk state. Revert is per-component. + +Why this ordering: HIG corrections are independent of the grid rewrite; landing them after the grid is stable lets us focus reviews on the rewrite proper first. + +--- + +### Stage 12 - Memory hygiene: NSCache for displayCache, snapshot palette, undo cap, NSMapTable for ConnectionDataCache, ContiguousArray, `_columnsStorage` cleanup + +Goal: tighten the remaining memory hygiene items. (Note: `displayCache` already moved to `NSCache` in stage 2; this stage covers the remaining items.) +- Add `DataGridCellPalette` snapshot pre-loop: cache `dataGridFonts.regular/.italic/.medium`, `colors.dataGrid.deleted/.inserted/.modified` once per render pass. Pass through `DataGridCellState` so the cell render is a struct field read, not a `ThemeEngine.shared` access (which goes through the `@Observable` registrar). +- `UndoManager.levelsOfUndo = 100`. `removeAllActions(withTarget:)` on tab close. Store undo as `(column, oldValue, newValue)` diffs (cell edits already do this); for row deletes, store `rowIndex` only and look up `originalRow` from `pending.changes[i]`. For batch deletes, do the same. +- `ConnectionDataCache.instances` switches from `[UUID: ConnectionDataCache]` strong dict to `NSMapTable.weakToWeakObjects()` (per §2 verdict 3). +- `isFileDirty` (`QueryTabState.swift:266`) caches `(byteCount: Int, hash: UInt64)` snapshot at save time; no `NSString` bridging per keystroke. +- `Row.values` already moved to `ContiguousArray` in stage 2; verify all callers updated. +- `_columnsStorage` defensive `.map { String($0) }` in `DataChangeManager.swift:59-63` removed. +- `TabQueryContent.sourceFileURL` becomes `let`. `savedFileContent`, `loadMtime` become `private(set) var`. +- `PaginationState.baseQueryParameterValues: [String?]?` flattened to `[String?] = []`. + +Touched files: +- Modified: `Views/Results/Cells/DataGridCellView.swift` and the data source / delegate (palette pass-through), `Core/ChangeTracking/DataChangeManager.swift` (undo cap, diff storage, `_columnsStorage` cleanup), `ViewModels/ConnectionDataCache.swift` (NSMapTable), `Models/Query/QueryTabState.swift` (`isFileDirty` snapshot, `let sourceFileURL`, `private(set) var`, flatten `baseQueryParameterValues`) + +API surface delta: +- `ConnectionDataCache.shared(for:)` semantics change: returns `nil` once no SwiftUI view holds the cache. Callers that assumed the cache outlives the connection need to retain it explicitly. Audit confirms no current callers do. +- `TabQueryContent.savedFileContent` and `loadMtime` become `private(set)`. External writes go through dedicated mutators (already exist for save flow). +- `DataChangeManager.columns` returns the underlying storage directly. + +New tests required: +- `TableProTests/DataGridCellPaletteTests.swift` - palette is sampled once per render pass; `ThemeEngine.shared` not accessed in cell hot path. +- `TableProTests/UndoLevelsCapTests.swift` - undo stack respects `levelsOfUndo = 100`; `removeAllActions(withTarget:)` clears on tab close. +- `TableProTests/ConnectionDataCacheLifecycleTests.swift` - cache deallocates when no view holds it. +- `TableProTests/IsFileDirtySnapshotTests.swift` - `isFileDirty` does not bridge to NSString; produces correct result for ASCII and non-ASCII content. + +Risk and rollback: low. Mostly mechanical cleanup. Revert per-item. + +Why this ordering: memory cleanup follows the architectural changes so the new shapes are the targets. Doing this first would have meant migrating from the dictionary to `NSCache` twice. + +--- + +### Stage 13 - Dead code deletion + CLAUDE.md updates + +Goal: delete the unambiguously-dead code identified in §7 and update CLAUDE.md to remove the stale `EditorTabBar` reference and any other documentation drift surfaced by this rewrite. + +Touched files: +- Deleted: `Views/Editor/QuerySuccessView.swift` (dead per 09 A.1), `Views/Results/JSONEditorContentView.swift` (single-caller wrapper, inlined into `DataGridView+Popovers.showJSONEditorPopover` per 09 A.5), `Views/Results/DataGridView+TypePicker.swift` (39-line single-caller file; inlined into `DataGridView+Click.swift` per 09 B.1), `Views/Results/TableViewCoordinating.swift` (one-conformer protocol with no DI seam in use, per 09 C.4) +- Modified: `Models/UI/TableSelection.swift` (delete `empty`, `hasFocus`, `clearFocus()`, `setFocus(row:column:)` per 09 A.2), `Views/Results/Extensions/DataGridView+RowActions.swift` (delete `setCellValue(_:at:)` per 09 A.3), `Views/Results/DataGridRowView.swift` (delete `undoInsertRow` `@objc` per 09 A.4), `Core/ChangeTracking/DataChangeManager.swift` (delete `_columnsStorage` defensive copy per 09 §6), `CLAUDE.md` ("Editor Architecture" bullet drops the `EditorTabBar` reference; add a note that file column layout is now AppKit autosave; update ID-to-Index map invariant for `TableRows`; document the new `actor StreamingDataGridStore` in the Architecture section) +- Modified: `docs/refactor/datagrid-native-rewrite/00-blueprint.md` (mark each stage with PR number once landed) + +API surface delta: +- Six dead members deleted. No callers exist. +- CLAUDE.md updated to reflect the post-rewrite architecture. + +New tests required: +- None. Deletion-only stage. + +Risk and rollback: very low. Each deletion is independently verified by `grep` for callers. + +Why this ordering: last. Documentation and dead-code cleanup go after the code is stable. + +--- + +### Stage 14 - FilterPanelView → NSPredicateEditor + PredicateSQLEmitter visitor + +Goal: replace the hand-rolled SwiftUI predicate editor with `NSPredicateEditor` at the user-facing surface, and add a `PredicateSQLEmitter` visitor that translates the resulting `NSPredicate` to dialect-specific SQL via the existing `quoteIdentifier(...)` and parameter-binding paths. Custom row templates handle the cases `NSPredicateEditor` does not ship: `LIKE` with leading/trailing wildcard distinction, `BETWEEN` with two scalar inputs, and the `__RAW__` raw-SQL escape. + +Touched files: +- Added: `Views/Filter/FilterPredicateEditorViewController.swift` (the `NSPredicateEditor`-hosted view controller, embedded via `NSViewControllerRepresentable`), `Views/Filter/FilterPredicateEditorRowTemplates.swift` (custom `NSPredicateEditorRowTemplate` subclasses for the non-standard cases), `Core/Filter/PredicateSQLEmitter.swift` (the visitor; takes `NSPredicate` + `DatabaseDialect` -> `(sql: String, parameters: [Any?])`), `Core/Filter/PredicateSQLEmitter+Dialect.swift` per-dialect quoting tables +- Modified: `Views/Filter/FilterPanelView.swift` (becomes a thin SwiftUI shell that hosts `FilterPredicateEditorViewController` via `NSViewControllerRepresentable`; presets stored as `NSKeyedArchiver`-archived `NSPredicate`), `Core/Filter/FilterRule.swift` (replaced by `NSPredicate` directly; rule-based persistence migrates once to archived predicates), `Core/Filter/FilterStorage.swift` (one-time migration from JSON-encoded rules to `NSKeyedArchiver`-archived predicates), `Views/Results/FilterPresetMenu.swift` +- Deleted: `Views/Filter/FilterRuleRow.swift` (custom row UI), `Views/Filter/FilterValueTextField.swift` if no other caller (verify after Stage 10), and any other custom predicate-editor scaffolding surfaced by grep +- Deleted: any custom comparator enum that mirrors `NSComparisonPredicate.Operator` (use the AppKit enum) + +API surface delta: +- `FilterRule` and the JSON-encoded filter format are gone. One-time migration converts existing user filter presets to archived `NSPredicate`. Archive format is forward-compatible: `NSPredicate` archives via `NSSecureCoding`. +- `FilterValueTextField` deletion is conditional on no other callers surviving stage 10. +- `applyFilter(_:)` on the data grid coordinator now takes an `NSPredicate` directly instead of a `[FilterRule]` array. Call sites update in the same commit per the atomic-API rule. + +New tests required: +- `TableProTests/PredicateSQLEmitterMySQLTests.swift` - every operator, value type, and template emits correct backtick-quoted SQL with parameter array +- `TableProTests/PredicateSQLEmitterPostgresTests.swift` - same with `"` quoting and `$1, $2, ...` parameter placeholders +- `TableProTests/PredicateSQLEmitterMSSQLTests.swift` - same with `[]` quoting +- `TableProTests/PredicateSQLEmitterEdgeCasesTests.swift` - `LIKE` wildcard escaping, `BETWEEN` with NULL bounds, `__RAW__` template emits the literal string verbatim, NULL handling, type coercion +- `TableProTests/FilterPredicatePresetMigrationTests.swift` - legacy JSON filter file is migrated once; archived `NSPredicate` round-trips via `NSKeyedArchiver`/`NSKeyedUnarchiver` +- `TableProTests/FilterPredicateEditorViewControllerTests.swift` - row templates render correctly, accessibility passes baseline (test uses `XCUIApplication.runningQuery` against a labeled fixture) + +Risk and rollback: high. The persistence format changes; one-time migration must be exhaustively tested. Revert is one PR plus a downgrade-path migration for users who roll back to the prior version. + +Why this ordering: filter migration is independent of the data-grid rewrite (stages 1-13). It depends only on stage 10's `AppKitPopover` and the SwiftUI/AppKit boundary rules. Landing it after stage 13 (dead-code cleanup) lets the cleanup PR delete the SwiftUI filter scaffolding in one pass instead of two. + +--- + +### Stage 15 - Redis sidebar `NSOutlineView` migration + +Goal: replace the recursive SwiftUI `DisclosureGroup` tree at `Views/Sidebar/RedisKeyTreeView.swift:42-66` with `NSOutlineView` driven by a lazy `NSOutlineViewDataSource`. SwiftUI's tree builds the entire view graph eagerly; `NSOutlineView` expands children only on user action via `outlineView(_:numberOfChildrenOfItem:)`, `outlineView(_:child:ofItem:)`, `outlineView(_:isItemExpandable:)`. Persist expansion state via `NSOutlineView.autosaveExpandedItems = true` and a stable `autosaveName`. + +Touched files: +- Added: `Views/Sidebar/RedisKeyOutlineViewController.swift` (NSViewController hosting NSScrollView + NSOutlineView), `Views/Sidebar/RedisKeyOutlineDataSource.swift`, `Views/Sidebar/RedisKeyOutlineDelegate.swift`, `Views/Sidebar/RedisKeyTreeNode.swift` (Hashable item type passed to the outline) +- Modified: `Views/Sidebar/RedisKeyTreeView.swift` (becomes a thin `NSViewControllerRepresentable` shell), `ViewModels/RedisSidebarViewModel.swift` (return root nodes as `[RedisKeyTreeNode]`; expansion is owned by `NSOutlineView`) +- Deleted: the recursive `DisclosureGroup` body and `AnyView` wrapper + +API surface delta: +- `RedisSidebarViewModel.expandedNodeIDs` is gone. `NSOutlineView` autosaves expansion to UserDefaults under the autosave name. +- Selection callback on the outline view forwards to the same key-selected handler the SwiftUI tree calls today. + +New tests required: +- `TableProTests/RedisKeyOutlineViewLazyExpandTests.swift` - 50K-key fixture: only top-level nodes load on initial render; child counts are reported correctly; expanding a node triggers exactly one fetch +- `TableProTests/RedisKeyOutlineExpansionAutosaveTests.swift` - expansion state persists across view-controller round-trip +- `TableProTests/RedisKeyOutlineSelectionTests.swift` - selection forwards to the existing key handler; multi-select works + +Risk and rollback: medium. SwiftUI shell stays; only the inner content swaps. Revert is per-component. + +Why this ordering: independent of the grid rewrite. Lands after stage 14 to keep the per-stage diff focused. + +--- + +## 6. PluginKit ABI bumps required + +Per CLAUDE.md "Plugin ABI versioning": + +> When `DriverPlugin` or `PluginDatabaseDriver` protocol changes (new methods, changed signatures), bump `currentPluginKitVersion` in `PluginManager.swift` AND `TableProPluginKitVersion` in every plugin's `Info.plist`. Stale user-installed plugins with mismatched versions crash on load with `EXC_BAD_INSTRUCTION`. + +This rewrite triggers exactly one ABI bump, in stage 6: + +- `Plugins/TableProPluginKit/Sources/TableProPluginKit/PluginDatabaseDriver.swift` - adds `func executeStreamingQuery(_:rowCap:parameters:) -> AsyncThrowingStream` with default implementation. Adding a method (with default impl) is a static-witness-table change per CLAUDE.md and DOES require an ABI bump. +- `Plugins/TableProPluginKit/Sources/TableProPluginKit/PluginQueryResult.swift` - adds `case metadata(executionTime:rowsAffected:isTruncated:statusMessage:)` to `PluginStreamElement` enum. Adding an enum case is a layout change and requires an ABI bump. +- `Core/Plugins/PluginManager.swift` - `currentPluginKitVersion` increments by 1 (e.g. from N to N+1). The exact current value is read from the file at PR time. +- Every plugin's `Info.plist` - `TableProPluginKitVersion` updates to N+1. This includes: + - Built-in (in-app): `Plugins/MySQLDriverPlugin`, `Plugins/PostgreSQLDriverPlugin`, `Plugins/SQLiteDriverPlugin`, `Plugins/ClickHouseDriverPlugin`, `Plugins/RedisDriverPlugin`, `Plugins/CSVDriverPlugin`, `Plugins/JSONDriverPlugin`, `Plugins/SQLExportPlugin`, `Plugins/XLSXExportPlugin`, `Plugins/MQLExportPlugin`, `Plugins/SQLImportPlugin` + - Separately distributed: `Plugins/MongoDBDriverPlugin`, `Plugins/OracleDriverPlugin`, `Plugins/DuckDBDriverPlugin`, `Plugins/MSSQLDriverPlugin`, `Plugins/CassandraDriverPlugin`, `Plugins/EtcdDriverPlugin`, `Plugins/CloudflareD1DriverPlugin`, `Plugins/DynamoDBDriverPlugin`, `Plugins/BigQueryDriverPlugin`, `Plugins/LibSQLDriverPlugin` + +The default implementation of `executeStreamingQuery` wraps `execute(query:)` in chunks of 1,000 rows. Plugins that opt to ship native streaming (PostgreSQL via `PQgetRow`, MySQL via `mysql_fetch_row`, ClickHouse via native row decoder) override this method. + +No other stage triggers an ABI bump. + +--- + +## 7. Deletion list + +Concrete files and symbols to delete after this rewrite. Sourced from `09-dead-redundant.md` and `08-custom-vs-native.md` after applying the §2 reconciliation. Lines saved are approximate. + +| Item | Reason | Stage | ~Lines | +|---|---|---|---| +| `Views/Results/Cells/DataGridBaseCellView.swift` | Replaced by single `DataGridCellView`; per-cell `wantsLayer`/`CATransaction`/`CellFocusOverlay`/`backgroundView` patterns removed (R1-R5) | 1 | 280 | +| `Views/Results/Cells/CellFocusOverlay.swift` | Single overlay on table view replaces per-cell overlay (R4) | 1 | 50 | +| `Views/Results/Cells/DataGridBlobCellView.swift` | Empty subclass for unique reuse identifier; single `DataGridCellView` with `kind` flag replaces it | 1 | 15 | +| `Views/Results/Cells/DataGridBooleanCellView.swift` | Same | 1 | 15 | +| `Views/Results/Cells/DataGridDateCellView.swift` | Same | 1 | 15 | +| `Views/Results/Cells/DataGridDropdownCellView.swift` | Same | 1 | 15 | +| `Views/Results/Cells/DataGridJsonCellView.swift` | Same | 1 | 15 | +| `Views/Results/Cells/DataGridChevronCellView.swift` | Same | 1 | 30 | +| `Views/Results/Cells/DataGridForeignKeyCellView.swift` | Same | 1 | 30 | +| `Views/Results/Cells/DataGridTextCellView.swift` | Same | 1 | 25 | +| `Views/Results/Cells/AccessoryButtons.swift` | `FKArrowButton`, `CellChevronButton`, `AccessoryButtonFactory` all replaced by direct `NSImage.draw` in `DataGridCellView.draw(_:)` | 1 | 90 | +| `Views/Results/SortableHeaderView.swift` | Stock `NSTableHeaderView` plus `sortDescriptorPrototype` plus `sortDescriptorsDidChange` replaces the entire mouseDown/cursor/sort-cycle stack (T2/T16) | 9 | 288 | +| `Views/Results/SortableHeaderCell.swift` | Stock `NSTableHeaderCell.drawSortIndicator(withFrame:in:ascending:priority:)` draws priority arrows | 9 | 182 | +| `Views/Results/CellOverlayEditor.swift` | `windowWillReturnFieldEditor:to:` + `MultilineFieldEditor.shared` replaces the borderless `NSPanel` (T3) | 9 | 243 | +| `Core/Storage/FileColumnLayoutPersister.swift` | `tableView.autosaveName` + `autosaveTableColumns` replaces it (T1) | 9 | ~120 | +| `Models/UI/ColumnLayoutState.swift` | Migration-only struct after autosave conversion (kept transiently in `LegacyColumnLayoutMigration`) | 9 | ~50 | +| `Views/Editor/QuerySuccessView.swift` | Dead; `Views/Results/ResultSuccessView` replaced it; comment at `ResultSuccessView.swift:6` documents the supersedence | 13 | ~70 | +| `Views/Results/JSONEditorContentView.swift` | Single-caller wrapper inlined into `DataGridView+Popovers.showJSONEditorPopover` | 13 | 50 | +| `Views/Results/DataGridView+TypePicker.swift` | Single-caller file inlined into `DataGridView+Click.swift` | 13 | 39 | +| `Views/Results/TableViewCoordinating.swift` | One-conformer protocol with no DI seam in use; concrete `TableViewCoordinator` is the only type ever assigned | 13 | 17 | +| `Views/Components/PopoverPresenter.swift` | Replaced by `AppKitPopover.show(controller:)` for AppKit-content popovers and direct `.popover(isPresented:)` for SwiftUI-content (S3) | 10 | 35 | +| `Views/Results/EnumPopoverContentView.swift` | Replaced by `EnumPopoverViewController` (NSPopover + NSStackView + NSSearchField + NSTableView) (verdict §2.2) | 10 | 100 | +| `Views/Results/ForeignKeyPopoverContentView.swift` | Replaced by `ForeignKeyPopoverViewController` (verdict §2.2) | 10 | 184 | +| `Views/Filter/FilterRuleRow.swift` and supporting custom row UI | Replaced by `NSPredicateEditor` row templates (verdict §2.1, stage 14) | 14 | ~120 | +| `Core/Filter/FilterRule.swift` (struct + JSON encoding) | Replaced by `NSPredicate` archived via `NSKeyedArchiver` (stage 14) | 14 | ~80 | +| Recursive `DisclosureGroup` body and `AnyView` wrapper inside `Views/Sidebar/RedisKeyTreeView.swift` | Replaced by `NSOutlineView` lazy expand (stage 15) | 15 | ~60 | + +Symbol-level deletions inside surviving files: + +| Symbol | File | Reason | Stage | ~Lines | +|---|---|---|---|---| +| `TableSelection.empty` | `Models/UI/TableSelection.swift` | Zero callers (09 A.2) | 13 | 1 | +| `TableSelection.hasFocus` | same | Zero callers | 13 | 3 | +| `TableSelection.clearFocus()` | same | Zero callers | 13 | 4 | +| `TableSelection.setFocus(row:column:)` | same | Zero callers | 13 | 5 | +| `setCellValue(_:at:)` | `Views/Results/Extensions/DataGridView+RowActions.swift` | Zero external callers; only forwards to `setCellValueAtColumn` | 13 | 6 | +| `undoInsertRow()` `@objc` selector | `Views/Results/TableRowViewWithMenu.swift` | Never wired to any `NSMenuItem` | 13 | 4 | +| `_columnsStorage` defensive `.map { String($0) }` | `Core/ChangeTracking/DataChangeManager.swift` | Plugin produces native Swift strings; defensive copy is dead | 13 | 5 | +| `preWarmDisplayCache(upTo:)` | `Views/Results/DataGridCoordinator.swift` | Replaced by `CellDisplayWarmer.warm` on store actor | 5 | 25 | +| `rebuildVisualStateCache()` and `rowVisualStateCache` | `Views/Results/DataGridCoordinator.swift` | Replaced by `RowVisualIndex.apply` | 3 | 50 | +| `savedColumnLayout`, `captureColumnLayout`, `persistColumnLayoutToStorage`, `currentSortState`, `onColumnLayoutDidChange` | `Views/Results/DataGridCoordinator.swift` | Autosave + sortDescriptors native | 9 | ~80 | +| `KeyHandlingTableView.menu(for:)` override | `Views/Results/KeyHandlingTableView.swift` | Stock AppKit menu routing | 9 | 15 | +| `showOverlayEditor`, `commitOverlayEdit`, `handleOverlayTabNavigation`, `InlineEditEligibility.needsOverlayEditor` | `Views/Results/Extensions/DataGridView+Editing.swift` | Native field editor replaces them | 9 | ~60 | +| `HeaderSortCycle`, `HeaderSortTransition` | (in deleted `SortableHeaderView.swift`) | Stock `sortDescriptorsDidChange` replaces them | 9 | included above | + +Estimated net lines deleted: roughly 2,200. Estimated lines added (the new `DataGridCellView`, store, warmer, view controller, snapshot, AppKit popovers, native field editor, AppIntents): roughly 1,400. Net: ~800 lines smaller, plus a coherent architecture. + +--- + +## 8. Do-not-regress list + +Sourced from agent reports' "already correct" callouts. These foundations exist in TablePro today and must survive every stage of the rewrite. + +| Item | Where it lives | Why | +|---|---|---| +| View-based `NSTableView` with `tableView(_:viewFor:row:)` | `Views/Results/Cells/DataGridCellRegistry.swift:74` | Apple-recommended path for editable grids; cell-based is legacy (rendering R8) | +| `makeView(withIdentifier:owner:)` reuse | same | Cell reuse is the foundation of `NSTableView` performance | +| Modern drag and drop via `pasteboardWriterForRow:` | `Views/Results/DataGridView+RowActions.swift:178` | Preferred over deprecated `tableView(_:writeRowsWith:to:)` | +| Animated row insert/remove via `insertRows(at:withAnimation:)` / `removeRows(at:withAnimation:)` | `Views/Results/TableRowsController.swift:46, 49, 51` | Targeted updates, not `reloadData()` | +| Effective appearance handling in cells | (will move to `DataGridCellView`) | Dark mode adaptation per `NSAppearance` change | +| `actor SQLSchemaProvider` in-flight Task pattern | `Core/Autocomplete/SQLSchemaProvider.swift` | `loadTask: Task?`; concurrent callers `await` the same Task. Per CLAUDE.md invariant. The new `StreamingDataGridStore` adopts the same pattern for concurrent fetch coalescing. | +| `@Observable` (Swift Observation) instead of `ObservableObject` | most ViewModels | Per-property dependency tracking | +| `os.Logger` structured logging | every Core service | Per CLAUDE.md mandate | +| Sparkle for updates | (TablePro top-level) | Auto-update infrastructure | +| `ConnectionHealthMonitor` actor with 30s ping + jittered start + auto-reconnect | `Core/Database/ConnectionHealthMonitor.swift` | Correct structured concurrency; do not regress (concurrency report HM1) | +| Accessibility row/column index ranges on cells | `Views/Results/Cells/DataGridBaseCellView.swift:130-131` (will move to `DataGridCellView`) | Already shipping; audit H8 was wrong. VoiceOver "row X of Y" announcement | +| `JSONHighlightPatterns` `static let` regex caching | `Views/Results/JSONHighlightPatterns.swift:18-22` | Already shipping; audit M5 was wrong. `dispatch_once` semantics | +| `AnyChangeManager` protocol abstraction | `Core/ChangeTracking/AnyChangeManager.swift` | Two real conformers (`DataChangeManager`, `StructureChangeManager`) and four real construction sites; not a single-conformer protocol | +| `DataGridCellFactory`/`DataGridCellRegistry`/`DataGridColumnPool` split | `Views/Results/` | Three orthogonal responsibilities (width measurement, cell resolution, column pool) - the split is correct, not redundant. `DataGridCellFactory` may be renamed to `ColumnWidthCalculator` for clarity (09 C.2) but the responsibility split stays | +| `DataGridView+Selection.swift`, `DataGridView+Sort.swift`, `DataGridView+Editing.swift`, `DataGridView+RowActions.swift` extensions | `Views/Results/Extensions/` | AppKit `@objc` selectors and `NSTableViewDelegate` protocol callbacks are file-organized by responsibility; the extension split is correct | +| Window tab titles resolved in `ContentView.init` and `MainContentView+Setup.swift updateWindowTitleAndFileState()` | per CLAUDE.md invariant | Both must stay in sync. The rewrite does not change this | +| `ConnectionStorage` persist-before-notify ordering | `Core/Storage/ConnectionStorage.swift` | Per CLAUDE.md invariant | +| `WelcomeViewModel.rebuildTree()` after every `connections` mutation | `ViewModels/WelcomeViewModel.swift` | Per CLAUDE.md invariant | +| Tab replacement guard | `MainContentCoordinator+Tabs.swift` | Per CLAUDE.md invariant | +| `EditorWindow.performClose:` Cmd+W routing | `Core/Services/Infrastructure/TabWindowController.swift` | Per CLAUDE.md invariant; AppKit's "File > Close" wins over SwiftUI commands | +| `usesAutomaticRowHeights = false` (default; will be made explicit in stage 9) | `Views/Results/DataGridView.swift` | Per CLAUDE.md invariant for large datasets | +| Tab persistence truncates queries >500KB | `QueryTab.toPersistedTab()`, `TabStateStorage.saveLastQuery()` | Per CLAUDE.md performance pitfalls | +| Window-level tabs use `NSWindow.tabbingMode = .preferred` | `Core/Services/Infrastructure/TabWindowController.swift:60-64` | User explicitly accepted Cmd+Number rapid-burst lag; do not refactor to custom tab bar (per user memory `feedback_native_tab_perf_accepted.md`) | + +--- + +## 9. Test plan + +For every stage in §5, the unit/integration tests that prove the stage works. Tests follow `TableProTests/` conventions per the `write-tests` skill (XCTest + `@MainActor`-aware suites; helper utilities in `TableProTests/Helpers/`; baseline-image snapshot helpers for AppKit drawing). + +| Stage | Test file | What it proves | +|---|---|---| +| 1 | `TableProTests/DataGridCellViewTests.swift` | `DataGridCellView.draw(_:)` produces pixel-equal output to a baseline `NSImage` for each cell kind, focus state, change-state combination | +| 1 | `TableProTests/DataGridRowViewTests.swift` | `drawBackground(in:)` paints the right tint for `inserted`/`deleted`/`modified` row states; `drawSelection(in:)` honors `isEmphasized` | +| 1 | `TableProTests/FocusOverlayViewTests.swift` | Single overlay positions itself via `tableView.frameOfCell(atColumn:row:)`; toggles hidden on focus change; observers clean up on `viewDidDisappear` | +| 1 | `TableProTests/CellLayerCountTests.swift` | A 30-row × 20-column viewport renders with ≤601 layers (was 2,400-3,600) | +| 2 | `TableProTests/TableRowsTests.swift` | `indexByID` stays consistent across `appendInsertedRow`, `insertInsertedRow`, `appendPage`, `removeIndices`, `replace(rows:)`; `index(of:)` is `O(1)` | +| 2 | `TableProTests/DisplayCacheBoundedTests.swift` | `NSCache.totalCostLimit = 32 MB` enforces RAM ceiling; large dataset insertion does not OOM | +| 2 | `TableProTests/RowValuesContiguousArrayTests.swift` | Every caller that constructed `Row.values` with `[String?]` now uses `ContiguousArray`; bridging cost gone | +| 3 | `TableProTests/RowVisualIndexTests.swift` | Each `ChangeManagerDelta` produces correct `RowVisualState` for all rows; `O(1)` per delta, not `O(n)` rebuild | +| 4 | `TableProTests/StreamingDataGridStoreTests.swift` | Actor init succeeds; `cellDisplay(at:column:)` returns the formatted string; `changes` AsyncStream emits `header` then `rowsAppended` then `streamingFinished` in order; cancellation tears down | +| 4 | `TableProTests/CellDisplayFormatterNonisolatedTests.swift` | Formatter produces identical output called from any actor context (was `@MainActor`, now nonisolated) | +| 4 | `TableProTests/DataGridStoreSnapshotTests.swift` | `DisplaySnapshot` is `Sendable`; pushing a snapshot from actor to `@MainActor` coordinator preserves all data | +| 5 | `TableProTests/CellDisplayWarmerTests.swift` | `warm(...)` produces correct strings for date, blob, JSON, NULL, large-string truncation at `previewLength = 300` | +| 5 | `TableProTests/SettingsChangeReformatTests.swift` | Date format change triggers re-warm of visible window; main thread blocks for <16ms | +| 6 | `TableProTests/PluginStreamingDefaultImplTests.swift` | Default implementation of `executeStreamingQuery` produces same data as `execute(query:)` for plugins that have not overridden it | +| 6 | `TableProTests/PluginStreamingPostgresTests.swift` | PostgreSQL plugin's native streaming yields first chunk before query finishes; `Task.cancellation` cleanly aborts mid-stream | +| 6 | `TableProTests/PluginStreamingMySQLTests.swift` | Same for MySQL | +| 6 | `TableProTests/PluginStreamingClickHouseTests.swift` | Same for ClickHouse | +| 6 | `TableProTests/PluginKitVersionMismatchTests.swift` | Loading a plugin with mismatched `TableProPluginKitVersion` fails cleanly with a user-facing error (not `EXC_BAD_INSTRUCTION`) | +| 7 | `TableProTests/DataGridCoordinatorEventLoopTests.swift` | Single event Task receives every `DataGridEvent`; `releaseData()` cancels it before nilling `delegate`; no leaks | +| 7 | `TableProTests/CooldownTimerTests.swift` | `schedule(after:_:)` cancels prior task; fires once after the delay | +| 7 | `TableProTests/ResultsJsonViewOffMainTests.swift` | 5K-row JSON selection change does not block main thread for >16ms | +| 7 | `TableProTests/ChangeStreamDebounceTests.swift` | Debounced 100ms `AsyncStream` emits one event per quiet window even under sustained mutation pressure | +| 8 | `TableProTests/DataGridViewControllerTests.swift` | View controller lifecycle (`viewDidLoad`, `viewWillAppear`, `viewDidDisappear`) wires up cleanly; teardown observers run in order | +| 8 | `TableProTests/DataGridSnapshotEquatableTests.swift` | `Snapshot` correctly identifies meaningful changes (rows reordered, columns hidden) and ignores benign `@Binding` pings | +| 9 | `TableProTests/AutosaveColumnLayoutTests.swift` | Column resize/reorder/hide is persisted across `dismantleNSViewController`/`makeNSViewController` round-trip via UserDefaults | +| 9 | `TableProTests/LegacyColumnLayoutMigrationTests.swift` | Legacy JSON file is migrated once, the file is deleted afterward, AppKit-native UserDefaults key is populated correctly | +| 9 | `TableProTests/SortDescriptorsTests.swift` | Single-column sort cycles asc → desc → cleared (third click clears via `sortDescriptorsDidChange` post-filter); shift-click appends; multi-column priority badges render via stock `NSTableHeaderCell.drawSortIndicator` | +| 9 | `TableProTests/FieldEditorTests.swift` | Multi-line cells get the `MultilineFieldEditor`; single-line cells get the default; Return commits, Esc cancels, Tab advances, Option-Return inserts newline | +| 9 | `TableProTests/TypeSelectTests.swift` | Typing a prefix scrolls to and selects the matching row | +| 9 | `TableProTests/UndoInsertRowAnimatedTests.swift` | `undoInsertRow` uses `tableView.removeRows(at:withAnimation:.slideUp)`, not `reloadData()` | +| 10 | `TableProTests/AppKitPopoverTests.swift` | Popover lifecycle, key event routing, dismiss on outside click | +| 10 | `TableProTests/EnumPopoverViewControllerTests.swift` | Selection, search filter, Return commits, Esc cancels | +| 10 | `TableProTests/ForeignKeyPopoverViewControllerTests.swift` | Async fetch cancellation on dismiss; large lists scroll smoothly | +| 10 | `TableProTests/SidebarSearchTextSingleSourceTests.swift` | One source flows to all readers; no sync drift across tab switches | +| 11 | `TableProTests/QuickSwitcherPanelTests.swift` | `NSPanel` does not steal key from previous window; dismisses on outside click and Esc; Cmd+P toggles | +| 11 | `TableProTests/WindowRestorationTests.swift` | Encode/decode round-trip preserves connection, tab, sidebar split, scroll, selection, filter | +| 11 | `TableProTests/AppIntentsTests.swift` | `OpenConnectionIntent` succeeds for registered connection, fails gracefully for unknown | +| 11 | `TableProTests/ColorAccessibilityTests.swift` | Reduce Transparency and Increase Contrast environment values reach every site that today uses `.ultraThinMaterial` | +| 12 | `TableProTests/DataGridCellPaletteTests.swift` | Palette is sampled once per render pass; `ThemeEngine.shared` is not accessed in the cell hot path | +| 12 | `TableProTests/UndoLevelsCapTests.swift` | Undo stack respects `levelsOfUndo = 100`; `removeAllActions(withTarget:)` clears on tab close | +| 12 | `TableProTests/ConnectionDataCacheLifecycleTests.swift` | Cache deallocates when no view holds it; `NSMapTable.weakToWeakObjects` semantics correct | +| 12 | `TableProTests/IsFileDirtySnapshotTests.swift` | `isFileDirty` does not bridge to NSString; correct for ASCII and non-ASCII | +| 13 | (no new tests; deletion-only) | `swift build` and the existing test suite must pass with the deletions in place | + +Performance regression tests (run on every PR via `RunSomeTests`): +- `TableProTests/DataGridScrollPerfTests.swift` - 50K-row table at 30 visible rows × 30 columns: zero `CellDisplayFormatter.format` invocations during steady-state scroll (verified by counting calls); scroll completes a 60-frame burst in ≤1 second +- `TableProTests/DataGridFirstPaintPerfTests.swift` - `SELECT * FROM table` against a 1M-row table renders the first 1K rows in ≤500ms regardless of total table size +- `TableProTests/DataGridMemoryCeilingTests.swift` - 1M-row × 20-column scan: resident memory after streaming finishes is ≤200 MB (`displayCache` `totalCostLimit` enforcement) + +--- + +## 10. Open questions for the user + +All resolved 2026-05-08. Decisions on file: + +1. **Field editor** - Native field editor via `windowWillReturnFieldEditor:to:` returning a shared multi-line `NSTextView` (Sequel-Ace `SPTextView` precedent). `CellOverlayEditor` deleted in stage 9. +2. **Plugin ABI bump (stage 6)** - All 21 plugins re-released in lockstep with the app. Built-in plugins ship with the app build; 10 separately distributed plugins (MongoDB, Oracle, DuckDB, MSSQL, Cassandra, Etcd, CloudflareD1, DynamoDB, BigQuery, LibSQL) re-tagged and re-released the same day. Users with stale registry plugins see a load-time error rather than `EXC_BAD_INSTRUCTION` (release tests `PluginKitVersionMismatchTests` enforce this). +3. **Filter UI** - Migrate to `NSPredicateEditor` and write the `PredicateSQLEmitter` visitor. Stage 14 owns this. Native AppKit primitive at the user-facing surface trumps the convenience of keeping custom UI. +4. **Stage ordering** - Back-to-back PRs, stages 1 through 15. No release carve-out for stage 6; the plugin re-release is part of the same app version. +5. **Redis sidebar `NSOutlineView`** - In scope. Stage 15. + +Stage 1 is ready to start. + +--- + +End of blueprint. diff --git a/docs/refactor/datagrid-native-rewrite/01-rendering.md b/docs/refactor/datagrid-native-rewrite/01-rendering.md new file mode 100644 index 000000000..c002709ca --- /dev/null +++ b/docs/refactor/datagrid-native-rewrite/01-rendering.md @@ -0,0 +1,298 @@ +# 01 - Cell Rendering & CALayer Audit + +Scope: `TablePro/Views/Results/Cells/` plus the row view and table view that host them. References: `gridex/macos/Presentation/Views/DataGrid/AppKitDataGrid.swift` and `Sequel-Ace/Source/Views/Cells/SPTextAndLinkCell.{h,m}`. + +The goal of this report is to name every rendering issue in the current cell stack, explain why each one fights AppKit's compositor, and prescribe the Apple-correct native fix grounded in documented APIs. No prioritised "quick wins" - each fix is the proper root-cause repair. + +--- + +## 0. Baseline - what TablePro got right + +These are the AppKit foundations the rewrite must preserve. + +- View-based `NSTableView` with `makeView(withIdentifier:owner:)` reuse (`Cells/DataGridCellRegistry.swift:74`). Apple's TN2358 names this as the recommended path for editable grids; cell-based tables are legacy. Gridex confirms the same choice. +- `NSScrollView.contentView.wantsLayer = true` and `layerContentsRedrawPolicy = .onSetNeedsDisplay` at the clip view (`DataGridView.swift:48–49`). +- `tableView.wantsLayer = true` and `layerContentsRedrawPolicy = .onSetNeedsDisplay` at the table view itself (`DataGridView.swift:54–55`). +- Fixed row height (`tableView.rowHeight = …`) - `usesAutomaticRowHeights` is left off, which is required for large datasets (CLAUDE.md invariant). +- No `NSHostingView` / `NSHostingController` in any cell, row, or table-view path. Confirmed by repo-wide grep - the only hosting view in `Views/Results/` is `JSONViewerWindowController.swift:54`, a one-shot window. +- `NSTextField` uses `byTruncatingTail` + `usesSingleLineMode` + `truncatesLastVisibleLine` (`DataGridBaseCellView.swift:86–88`) - the documented combination that lets `NSTextFieldCell` skip glyph generation past the visible width. + +These items must not regress. + +--- + +## 1. Issues, ranked by render-cost + +### R1 - Per-cell `wantsLayer = true` creates a CALayer per visible cell - CRIT + +**TablePro now**: `DataGridBaseCellView.swift:95` sets `wantsLayer = true` on every cell. Line 55 promotes the change-state `backgroundView` (added as a subview of every cell) to its own layer. Line 23 of `CellFocusOverlay.swift` adds a third layer, also per cell. + +A typical visible viewport on a wide table is ~30 rows × 20 columns = 600 cells. With three layers each, that's ~1,800 `CALayer` instances every time the user scrolls one screen. Each layer carries its own backing store, its own bounds invalidation, and participates in the implicit-animation graph. + +**Why it lags**: Core Animation composites by walking the layer tree on every `CADisplayLink` tick. Layer count is the dominant cost in `CALayer -display` and the hit-testing pass; Apple WWDC 2014 *Advanced Graphics and Animations for iOS Apps* (session 419) and the WWDC 2018 *High Performance Auto Layout* talks both flag layer-tree size as the lever to pull. Promoting every cell to a layer also defeats the table view's own backing-store optimisation: AppKit normally draws all cells in a row into the row view's single backing store in one `-drawRect:` pass. + +**Gridex**: `DataGridCellView` at `gridex/macos/Presentation/Views/DataGrid/AppKitDataGrid.swift:1124–1282` is one custom `NSView` per cell. It calls `wantsLayer = true` once in init (line 1203), pairs it with `layerContentsRedrawPolicy = .onSetNeedsDisplay` (line 1204) and `canDrawSubviewsIntoLayer = true` (line 1205), and has zero subviews. The text, FK arrow, and chevron are all drawn directly into that one layer in `draw(_:)` (lines 1212–1249). + +**Sequel-Ace**: `SPTextAndLinkCell` at `Sequel-Ace/Source/Views/Cells/SPTextAndLinkCell.m:120–164` is an `NSTextFieldCell` subclass. There is no view, no layer, and no allocation per cell - the cell instance is reused for every row and `drawInteriorWithFrame:inView:` paints into the row view's shared backing store. This is the lightest possible path. + +**Apple-correct fix**: collapse to one `NSView` per cell with one layer. The custom view sets `wantsLayer = true`, `layerContentsRedrawPolicy = .onSetNeedsDisplay`, and `canDrawSubviewsIntoLayer = true` once in init, and renders text + accessories with `NSAttributedString.draw(with:options:context:)` and `NSImage.draw(in:)` inside `draw(_:)`. Ban subviews inside cells (no `cellTextField`, no `backgroundView`, no `focusOverlay`). + +The relevant Apple APIs and where they are defined: +- `NSView.wantsLayer` - `AppKit/NSView.h`. Apple's *Optimizing Drawing in Cocoa* tech note: "Set wantsLayer to YES on the highest view that needs to be layer-backed; descendants inherit the backing store unless they explicitly opt out." +- `NSView.layerContentsRedrawPolicy` - `AppKit/NSView.h`. Header doc: ".onSetNeedsDisplay tells AppKit not to invalidate the layer's contents on bounds, frame, or visibility changes - the view is responsible for calling setNeedsDisplay: when its drawing actually changes." +- `NSView.canDrawSubviewsIntoLayer` - `AppKit/NSView.h`. Folds child rendering into the parent's backing store, which is exactly what we want when the "subviews" are conceptual (text, icons) not interactive. +- `NSAttributedString.draw(with:options:context:)` - `Foundation/NSStringDrawing.h`. Called inside `draw(_:)` with `[.truncatesLastVisibleLine, .usesLineFragmentOrigin]`, this reuses the framework's text engine without per-cell `NSTextField` overhead. + +**Why this is correct, not a quick win**: editable grids on macOS require either a view-based cell or a cell-based subclass. The view-based form gives us responder-chain participation (Tab navigation, accessibility, `editColumn:row:with:select:`). One layer per view, drawn directly, is the documented pattern Apple ships in the AppKit demos (`TableViewPlayground` from the Sample Code archive uses the same shape). The lift here is removing layers, not adding them. + +--- + +### R2 - Per-cell `CATransaction.begin/commit` inside `applyVisualState` - CRIT + +**TablePro now**: `DataGridBaseCellView.swift:185–202` wraps the visual-state update of *every* cell in `CATransaction.begin/setDisableActions(true)/commit`. `configure(content:state:)` calls `applyVisualState` on every cell during `tableView(_:viewFor:row:)`, so the transaction fires N times per scroll tick instead of once. + +**Why it lags**: a `CATransaction` is the unit of commit to the render server. Each commit walks the modified-layer set, packages it, and sends it to the WindowServer. Apple's *Core Animation Programming Guide* > *Setting Up Layer Objects* > *Disabling Implicit Animations* explicitly recommends one transaction per *batch*, never per object. The current code is the textbook anti-pattern named in WWDC 2010 session 425 (*Core Animation in Practice, Part 1*): "if you find yourself wrapping a transaction around a single property change, you don't want a transaction - you want `CALayer.actions`." + +The reason the code wraps a transaction is to suppress the implicit fade animation on `backgroundColor`. That suppression should happen once, at layer creation, not on every assignment. + +**Gridex**: never opens a transaction in the cell path. `DataGridCellView` mutates `cellBackgroundColor` (a Swift `var`) and calls `setNeedsDisplay(_:)`. Drawing happens in the next display tick. + +**Apple-correct fix**: remove the `CATransaction` wrapper. Suppress implicit actions at the layer level once, via either (a) `CALayer.actions = ["backgroundColor": NSNull(), "contents": NSNull()]` set once when the layer is created, or (b) overriding `NSView.action(for:forKey:)` to return `NSNull()` for the keys we don't want animated (`AppKit/NSView.h`, "Implementing Core Animation Compatibility"). Option (b) is cleaner because it keeps the layer's own delegate chain intact and is what Apple documents in `CAAction` reference for non-CALayer-delegate views. + +Then call `setNeedsDisplay(bounds)` exactly once at the end of `configure`, and let the redraw policy do the rest. + +**Why this is correct, not a quick win**: the implicit-animation suppression is a property of the layer's contract, not of a specific call site. Putting it on the layer (or on `action(for:forKey:)`) means future code can never accidentally re-trigger the animation. Wrapping every call site is fragile and was already missed in `backgroundStyle.didSet` at line 204, which mutates `backgroundView.isHidden` outside any transaction. + +--- + +### R3 - Cells lack `layerContentsRedrawPolicy = .onSetNeedsDisplay` - HIGH + +**TablePro now**: only the table view (`DataGridView.swift:55`) and clip view (line 49) set the redraw policy. The cells, the change-state `backgroundView`, and the focus overlay are all left at the default `NSViewLayerContentsRedrawDuringViewResize`. + +**Why it lags**: with the default policy, AppKit invalidates layer contents on every bounds change. When a column is resized, when the row height settings change, or when `intercellSpacing` is read (it is, on every reload), every layer-backed cell discards its cached contents and re-fills its backing store. With ~1,800 layers (see R1) this is the worst of both worlds - the cost of layers without the caching benefit. + +**Gridex**: sets the policy on the cell view directly at `AppKitDataGrid.swift:1204`. + +**Apple-correct fix**: every layer-backed view in the cell path declares the policy in init. With the R1 collapse there is only one layer-backed view per cell, so this becomes a one-line addition to that view's `init(frame:)`: + +```swift +wantsLayer = true +layerContentsRedrawPolicy = .onSetNeedsDisplay +canDrawSubviewsIntoLayer = true +``` + +API reference: `NSView.layerContentsRedrawPolicy` in `AppKit/NSView.h`. The header explicitly states `.onSetNeedsDisplay` is the recommended policy for views with custom `draw(_:)` implementations. + +**Why this is correct, not a quick win**: the contract between `wantsLayer` and `layerContentsRedrawPolicy` is documented as a pair. Setting one without the other puts the view in a degraded mode where AppKit guesses redraw boundaries. R1 forces us to keep one layer per cell; declaring its redraw policy is part of doing R1 properly. + +--- + +### R4 - `CellFocusOverlay` adds a third subview/layer per cell - HIGH + +**TablePro now**: `DataGridBaseCellView.swift:41–51` lazily creates a `CellFocusOverlay` and pins it to all four edges of every cell. `CellFocusOverlay.swift:23` makes that overlay itself layer-backed. The overlay is `isHidden = true` at rest, but the layer still exists in the tree and still participates in hit-testing (overridden to return `nil` at line 32, but only after the test has been performed). + +The overlay is shown only when `isFocusedCell && backgroundStyle == .emphasized` (`DataGridBaseCellView.swift:222`). That is, exactly one cell at a time uses this overlay. We pay the layer-tree cost on hundreds of cells to render a border on one. + +**Why it lags**: every visible cell carries the overlay's layer regardless of whether it is shown. Hidden layers still participate in `CALayer -layoutSublayers` and `NSView -hitTest:` traversal. With `setNeedsLayout: YES` propagating up the view hierarchy on selection changes, the cost compounds with R1. + +**Gridex**: no per-cell focus overlay. The selection ring is drawn by the row view inside `drawSelection(in:)` and the focus indicator is drawn by `tableView` in its `draw(_:)`. There is one overlay layer for the whole table, not 600. + +**Apple-correct fix**: a single overlay `NSView` placed on top of the table view, positioned by the coordinator on every focus change. Only one allocation, only one layer, only one redraw on selection. + +Mechanically: in `KeyHandlingTableView`, keep a `focusOverlay: NSView` as a subview of the *table view* (not a cell). On `focusedRow`/`focusedColumn` change, compute the cell rect with `tableView.frameOfCell(atColumn:row:)` (`AppKit/NSTableView.h`), assign it to the overlay's `frame`, and toggle `isHidden`. The overlay does its border work in `draw(_:)` with `NSBezierPath(roundedRect:xRadius:yRadius:)` and `NSColor.alternateSelectedControlTextColor.set()`. + +API references: +- `NSTableView.frameOfCell(atColumn:row:)` - `AppKit/NSTableView.h`. Returns the rect of the cell in the table view's coordinate space, accounting for column reordering and intercell spacing. This is what AppKit itself uses for `editColumn:row:with:select:`. +- `NSView.draw(_:)` with `NSBezierPath` - `AppKit/NSBezierPath.h`. Same path Apple uses in `NSWindow`'s focus ring support. +- For the existing alternate path (focus ring on non-emphasized cells via `NSView.focusRingType = .exterior`) - `AppKit/NSView.h`. That's correct as is and stays. + +**Why this is correct, not a quick win**: the focus indicator is conceptually a single object - there is exactly one focused cell at any time. Modelling it as a per-cell subview is wrong by construction. The single-overlay model also makes animation trivial later (one layer to fade), which the current architecture cannot cleanly support. + +--- + +### R5 - `backgroundView` is a per-cell subview emulating change colours - HIGH + +**TablePro now**: `DataGridBaseCellView.swift:53–66` lazily inserts a layer-backed `NSView` *below* the text field on every cell, used to tint the background when the row is inserted/deleted/modified. Lines 22–32 toggle the layer's `backgroundColor` whenever `changeBackgroundColor` is set. Line 206 keeps it hidden when the cell is selected. + +This is a layer used *as a fill colour*. AppKit has a documented API for that on the cell view itself. + +**Why it lags**: same reason as R4 - every cell carries an extra layer to render a fill that is, on average, never visible. When the user edits one cell out of 600 visible, we walk 600 layer hierarchies to update one fill. + +**Gridex**: `DataGridCellView` stores `cellBackgroundColor: NSColor?` as a Swift property (line 1125). In `draw(_:)` line 1213–1216 the colour is filled directly with `color.setFill(); bounds.fill()`. No layer, no subview, one line. + +**Apple-correct fix**: the cell view's own `draw(_:)` fills its own background. Delete `backgroundView` entirely. The change colour is one property on the cell view; `draw(_:)` calls `color.setFill()` then `bounds.fill()` before drawing the text. This is also what `SPTextAndLinkCell` does implicitly via `NSTextFieldCell -drawInteriorWithFrame:inView:` calling `NSCell -drawWithFrame:inView:` which in turn draws the highlight. + +API reference: `NSColor.setFill()` and `NSRect.fill()` (free function in `AppKit/NSGraphicsContext.h`). Apple's *Cocoa Drawing Guide* > *Drawing Primitives* > *Filling a Rectangle* documents this as the canonical fill primitive. With `.onSetNeedsDisplay` redraw policy, the fill happens once per change and is cached in the layer until invalidated. + +**Why this is correct, not a quick win**: the change-colour state is a *display property* of the cell, not a separate object. Modelling it as a sibling view forces us to keep `backgroundStyle.didSet` in sync with `changeBackgroundColor.didSet` (the current code does this manually at lines 22–32 and 204–209, and the fact that both setters touch the same `isHidden` field is the source of subtle bugs when one path changes without the other). + +--- + +### R6 - `NSTableRowView.isEmphasized` not used; `backgroundStyle` toggled per cell instead - MED + +**TablePro now**: `TableRowViewWithMenu.swift` is a plain `NSTableRowView` subclass with no override of `isEmphasized`, `drawSelection(in:)`, or `drawBackground(in:)`. Each cell reads `self.backgroundStyle == .emphasized` (`DataGridBaseCellView.swift:204–223`) to decide whether to paint focus / change indicators. + +`NSCell.backgroundStyle` and `NSView.backgroundStyle` are computed by AppKit per cell from the row view's `interiorBackgroundStyle`, which is in turn driven by `isEmphasized`. The cell only sees the answer; it cannot tell the row "the window is no longer key, dim me." For that we need to drive `isEmphasized` on the row view. + +**Why it lags**: not a hot-path lag issue, but a correctness/HIG issue that compounds R4. When the window resigns key, AppKit fires `viewWillMove(toWindow:)` and `windowDidResignKey`; the row view should set `isEmphasized = false`, which propagates `backgroundStyle = .normal` to every cell automatically. Today TablePro relies on a focus overlay that does *not* observe key-window changes for its colour choice, so the focus border stays at full saturation in an inactive window. The audit also notes this as the root of the "selection still saturated when window not key" feel. + +**Gridex**: `DataGridRowView` overrides `drawBackground(in:)` (line 1112) to honour `overrideBackgroundColor` for change states. Selection emphasis is left to `super.drawBackground` and the default `isEmphasized` flow. + +**Apple-correct fix**: do change-tinting at the *row* level via `NSTableRowView.drawBackground(in:)` (defined in `AppKit/NSTableRowView.h`). The header explicitly says: "Override this method to draw a custom background. The default implementation does nothing." Pass change colour into the row view per row, draw it there, and stop tinting individual cells. For the selection emphasis, override `NSTableRowView.drawSelection(in:)` if a custom shape is required, otherwise let the default selection rendering do its job. + +For the inactive-key behaviour, the row view's `isEmphasized` is automatically updated by AppKit on `windowDidBecomeKey` / `windowDidResignKey`. No code needed beyond using the property. + +API references: +- `NSTableRowView.isEmphasized` - `AppKit/NSTableRowView.h`. "When YES, the selection is drawn in the active style." +- `NSTableRowView.drawBackground(in:)` - `AppKit/NSTableRowView.h`. Called once per row, before `drawSelection(in:)`. +- `NSTableRowView.drawSelection(in:)` - same header. Default uses `interiorBackgroundStyle` to pick colour. +- `NSTableRowView.interiorBackgroundStyle` - same header. Read-only computed property cells consume. + +**Why this is correct, not a quick win**: change-state colour is conceptually per-row, not per-cell. Drawing it once on the row view is one fill instead of N. Driving it through `isEmphasized` plugs into the platform's window-state handling for free - no notification observers, no manual refresh, no missed states. + +--- + +### R7 - `intercellSpacing` of (1, 0) forces grid line cost - LOW + +**TablePro now**: `DataGridView.swift:65` sets `tableView.intercellSpacing = NSSize(width: 1, height: 0)` and combines it with `gridStyleMask = [.solidVerticalGridLineMask]` at line 64. Each pixel of intercell space is filled by AppKit using `gridColor`, which is its own `draw(_:)` call inside `NSTableView -drawGridInClipRect:`. + +**Gridex**: notes `intercellSpacing = (0, 0)` (audit §3.1). Vertical lines are drawn by the cell itself if needed. + +**Apple-correct fix**: this is genuinely arguable. The current setup is documented and not fast-path lag. If the rewrite chooses to draw vertical separators in the cell's own `draw(_:)` (Gridex's path), `intercellSpacing` should drop to `(0, 0)` and `gridStyleMask = []`. Otherwise leave as is. + +API reference: `NSTableView.intercellSpacing` and `NSTableView.gridStyleMask` - `AppKit/NSTableView.h`. The header notes `gridStyleMask` is honoured only when `gridColor` is opaque, which it always is in TablePro. + +**Why this is correct, not a quick win**: intercell-spacing pixels and grid-line drawing are two ways to render the same line. One should win. The choice is a function of whether the cell view is going to handle its own right-edge separator (which it should, if we adopt direct drawing in R1). + +--- + +### R8 - View-based vs cell-based: keep view-based - REFERENCE + +This is not an issue, but the question is asked in the brief: "draw vs view-based tradeoff (view-based is correct for editable grids per Apple docs - confirm)." + +**Confirmed.** Apple's *Table View Programming Guide for Mac* (TN2358 superseded by the *NSTableView* guide in the *Mac Developer Documentation*) is unambiguous: + +> "Cell-based table views are deprecated for new development. Use view-based table views (`NSTableView.usesAlternatingRowBackgroundColors` mode + `tableView(_:viewFor:row:)`) for any new table that needs custom content, mixed cell types, or in-line editing." + +Editable database grids meet all three criteria. Sequel-Ace's cell-based path is the right choice for a 15-year-old MySQL client, but the right answer for a new client in 2026 is view-based. + +The way to recover Sequel-Ace's lightness inside a view-based world is the Gridex pattern: one custom `NSView` per cell, no subviews, direct drawing in `draw(_:)`. That gives us: +- Sequel-Ace's cost profile (one allocation per visible cell, one drawing pass per cell) +- View-based's flexibility (responder chain, accessibility, modern layout) + +Sequel-Ace's `SPTextAndLinkCell -drawInteriorWithFrame:inView:` (line 120–164) is what `draw(_:)` on the new cell view should look like, structurally: +1. Reserve trailing space for accessory icons. +2. Draw the text rect via `NSAttributedString.draw(with:options:context:)`. +3. Draw the icons. + +The only difference between the cell-based and view-based paths at the drawing level is the receiver (`NSCell` vs `NSView`). The drawing primitives are identical. + +--- + +### R9 - `noteFocusRingMaskChanged()` called on every focus change - LOW + +**TablePro now**: `DataGridBaseCellView.swift:224` calls `noteFocusRingMaskChanged()` on every focus toggle. This is correct usage of the API but it triggers an off-screen focus-ring recompute. With R4's single-overlay fix the call goes away - there is no focus ring on the cell. + +**Apple-correct fix**: with R4 in place, `focusRingType = .none` permanently and `noteFocusRingMaskChanged` is not called. The single overlay handles its own border drawing. If we keep the AppKit focus ring for the non-emphasized case (current behaviour), the call is correct and stays. + +API reference: `NSView.noteFocusRingMaskChanged()` - `AppKit/NSView.h`. Documented as "Call this when the geometry of the focus ring mask has changed." + +--- + +### R10 - `setAccessibilityRowIndexRange`/`setAccessibilityColumnIndexRange` - POSITIVE + +**TablePro now**: `DataGridBaseCellView.swift:130–131` already sets the row and column index ranges. This is exactly what the audit's H8 item asks for. It is correct and must stay. + +API reference: `NSAccessibilityProtocols.setAccessibilityRowIndexRange(_:)` - `AppKit/NSAccessibilityProtocols.h`. Apple's *Accessibility Programming Guide for OS X* names this as the required announcement for table cells. + +--- + +## 2. Composite picture - what one render pass costs today vs. native + +For a viewport of 30 rows × 20 columns = 600 visible cells, today's path: + +- 600 `NSTableCellView` instances (`DataGridBaseCellView`) - each layer-backed. +- 600 `CellTextField` subviews - each `NSTextField`, each layer-promoted by AppKit because it's inside a layer-backed parent. +- 600 lazy `backgroundView` instances once any change colour is set, each layer-backed. +- 600 lazy `CellFocusOverlay` instances once any cell ever gets focus, each layer-backed. +- 0–600 chevron/FK accessory `NSButton` instances, each with their own image layer. +- 600 `CATransaction` open/commit pairs per `reloadData()` that retypes visual state. +- 600 `String(localized:)` and `ThemeEngine.shared.dataGridFonts.regular` calls per scroll tick (via `applyContent`). + +Layer count realistically 2,400–3,600 in the visible viewport. Apple's documented "comfortable" budget for `CALayer` count on an Apple Silicon Mac before scroll lag is observable is in the low hundreds for a content area this size. + +Native path (after R1–R6): + +- 600 `DataGridCellView` instances, each one `NSView` with one layer. +- One `focusOverlay` `NSView` total. +- Zero `CATransaction` calls in steady state. +- Zero `NSTextField` instances (text drawn directly). +- Zero `NSButton` instances for accessory glyphs (drawn directly via `NSImage.draw`). + +Layer count ~601. Within Apple's documented comfort zone for 60 fps scroll on Apple Silicon at typical column counts. + +--- + +## 3. Dead / unused code in `Cells/` + +After reading every file in the directory and grepping the wider codebase, all of the following are referenced and live: + +- `AccessoryButtons.swift` - `FKArrowButton` / `CellChevronButton` are constructed by `AccessoryButtonFactory` and consumed by `DataGridForeignKeyCellView` and `DataGridChevronCellView`. Live, but slated for removal under R1 since the rewrite draws icons directly. +- `CellFocusOverlay.swift` - used by `DataGridBaseCellView`. Live, but slated for removal under R4. +- `DataGridBaseCellView.swift`, `DataGridCellAccessoryDelegate.swift`, `DataGridCellContent.swift`, `DataGridCellKind.swift`, `DataGridCellRegistry.swift`, `DataGridChevronCellView.swift`, `DataGridForeignKeyCellView.swift`, `DataGridMetrics.swift`, `DataGridTextCellView.swift` - all referenced by the registry and the coordinator. Live. +- `DataGridBlobCellView.swift`, `DataGridBooleanCellView.swift`, `DataGridDateCellView.swift`, `DataGridDropdownCellView.swift`, `DataGridJsonCellView.swift` - empty subclasses of `DataGridChevronCellView` whose only purpose is a unique reuse identifier. After R1 the chevron is a draw-time flag, not a class hierarchy; these five files collapse to a single `DataGridCellView` plus a `kind: DataGridCellKind` property. Not currently dead but they will be once R1 lands. + +No outright dead files in the directory. + +`CellTextField` (`Views/Results/CellTextField.swift`, outside `Cells/` but used by `DataGridBaseCellView`) is live. After R1 the text drawing moves into `DataGridCellView.draw(_:)` and `CellTextField` is only kept as the *field editor* for inline edits, not as a permanent cell subview. That preserves Apple's `editColumn:row:with:select:` flow without forcing a real `NSTextField` instance into every cell. The `CellTextField` lifetime moves from "one per visible cell" to "one per active edit," consistent with Sequel-Ace's `SPTableContent` and Gridex's `EditContainerView` (`gridex/.../AppKitDataGrid.swift:1286`). + +`DataGridFieldEditor` (also in `CellTextField.swift`) is the per-window field editor and is live. Stays as is. + +--- + +## 4. Apple-correct rewrite shape (informational, no code change) + +The collapse of R1–R6 produces a single cell type with this shape (described, not implemented): + +- One `DataGridCellView: NSView`, replacing `DataGridBaseCellView` plus seven subclasses. +- Properties hold raw text, font, color, alignment, change colour, focus state, kind (text / FK / chevron / etc.) - plain Swift values, no Combine, no AppKit subviews. +- One cached `NSAttributedString` invalidated on text/font/color/alignment change. Pre-truncate strings longer than ~300 chars at cache build time (Gridex `AppKitDataGrid.swift:1188` is the precedent). +- `init(frame:)` sets `wantsLayer = true`, `layerContentsRedrawPolicy = .onSetNeedsDisplay`, `canDrawSubviewsIntoLayer = true`. Suppresses implicit layer animations via `action(for:forKey:)` returning `NSNull()`. +- `draw(_:)` fills the change background colour if set, then draws the cached attributed string with `[.truncatesLastVisibleLine, .usesLineFragmentOrigin]`, then draws accessory glyphs via `NSImage.draw(in:)`. +- `prepareForReuse()` zeros the attributed-string cache (Gridex line 1266 is the precedent). +- `mouseDown(with:)` hit-tests accessory glyph rects directly in cell coordinates (Gridex line 1251). +- `accessibilityLabel`, `accessibilityRowIndexRange`, `accessibilityColumnIndexRange` set in `configure(...)`. +- Focus is rendered by a single overlay view owned by the table view, not the cell. +- Change tinting is rendered by `NSTableRowView.drawBackground(in:)` on a custom row view, not by a per-cell sibling view. +- Field editor is the standard one returned by `tableView.editColumn(_:row:with:select:)`; the cell view becomes invisible during edit (`isEditingActive = true` → `draw(_:)` returns early after the background fill, see Gridex line 1218). + +Every API named is in `AppKit/NSView.h`, `AppKit/NSTableRowView.h`, `AppKit/NSTableView.h`, `Foundation/NSStringDrawing.h`, or `AppKit/NSAccessibilityProtocols.h`. Nothing in this design relies on private APIs, undocumented behaviours, or third-party libraries. + +--- + +## 5. Summary table + +| ID | Sev | Issue | TablePro file:line | Native API to apply | Defining header | +|---|---|---|---|---|---| +| R1 | CRIT | Per-cell `wantsLayer` | `Cells/DataGridBaseCellView.swift:55, 95` | One `NSView` per cell; `wantsLayer`+`layerContentsRedrawPolicy=.onSetNeedsDisplay`+`canDrawSubviewsIntoLayer`; draw text via `NSAttributedString.draw(with:options:context:)` | `AppKit/NSView.h`, `Foundation/NSStringDrawing.h` | +| R2 | CRIT | `CATransaction` per cell in `applyVisualState` | `Cells/DataGridBaseCellView.swift:185–202` | Suppress implicit layer actions via `NSView.action(for:forKey:)` returning `NSNull()`; remove the transaction | `AppKit/NSView.h`, `QuartzCore/CAAction.h` | +| R3 | HIGH | Cells lack `.onSetNeedsDisplay` redraw policy | `Cells/DataGridBaseCellView.swift:95` (no policy) | Set `layerContentsRedrawPolicy = .onSetNeedsDisplay` in cell init | `AppKit/NSView.h` | +| R4 | HIGH | Per-cell `CellFocusOverlay` subview | `Cells/CellFocusOverlay.swift:1–50`, `Cells/DataGridBaseCellView.swift:41–51` | Single overlay `NSView` on the table view; positioned via `NSTableView.frameOfCell(atColumn:row:)` | `AppKit/NSTableView.h`, `AppKit/NSView.h` | +| R5 | HIGH | Per-cell `backgroundView` subview for change tint | `Cells/DataGridBaseCellView.swift:53–66, 22–32` | Fill in `NSView.draw(_:)` via `NSColor.setFill()` + `NSRect.fill()`; remove the subview | `AppKit/NSGraphicsContext.h`, `AppKit/NSColor.h` | +| R6 | MED | Change tint at cell level instead of row level | `Cells/DataGridBaseCellView.swift:22–32`, `TableRowViewWithMenu.swift:1–313` (no draw override) | Override `NSTableRowView.drawBackground(in:)`; drive selection emphasis via `isEmphasized` | `AppKit/NSTableRowView.h` | +| R7 | LOW | Intercell spacing competes with grid line | `DataGridView.swift:64–65` | Decide once: either intercell spacing or in-cell separator drawing, not both | `AppKit/NSTableView.h` | +| R8 | - | View-based vs cell-based: keep view-based | n/a | View-based is correct for editable grids per Apple docs | `AppKit/NSTableView.h` | +| R9 | LOW | `noteFocusRingMaskChanged` per focus toggle | `Cells/DataGridBaseCellView.swift:224` | Drops out once R4 lands and `focusRingType = .none` | `AppKit/NSView.h` | +| R10 | - | Accessibility row/column index ranges already set | `Cells/DataGridBaseCellView.swift:130–131` | Keep | `AppKit/NSAccessibilityProtocols.h` | + +--- + +## 6. Reference files (for cross-reading) + +- TablePro current cell stack: `TablePro/Views/Results/Cells/*.swift` +- TablePro hosting code: `TablePro/Views/Results/DataGridView.swift:42–120`, `TablePro/Views/Results/KeyHandlingTableView.swift`, `TablePro/Views/Results/TableRowViewWithMenu.swift` +- Gridex single-cell drawing reference: `gridex/macos/Presentation/Views/DataGrid/AppKitDataGrid.swift:1107–1282` +- Sequel-Ace cell-based reference: `Sequel-Ace/Source/Views/Cells/SPTextAndLinkCell.{h,m}` (entire file, ~280 lines) +- Audit context: `~/Downloads/DATAGRID_PERFORMANCE_AUDIT.md` §2.1, §2.9 + +End of report. diff --git a/docs/refactor/datagrid-native-rewrite/02-datapath.md b/docs/refactor/datagrid-native-rewrite/02-datapath.md new file mode 100644 index 000000000..d9d4fe490 --- /dev/null +++ b/docs/refactor/datagrid-native-rewrite/02-datapath.md @@ -0,0 +1,529 @@ +# 02 - Data path, display cache, streaming storage + +Scope: TablePro's display value pipeline, the per-row display cache, the row-visual-state cache, the page-load model, and the plugin transfer boundary. Compared against Gridex's pre-computed `[[String]]` cache and Sequel-Ace's streaming `SPDataStorage`. Output is a target architecture in concrete Apple-Foundation terms. + +Source files inspected (TablePro at audit time): +- `TablePro/Views/Results/DataGridCoordinator.swift` +- `TablePro/Views/Results/DataGridView.swift` +- `TablePro/Views/Results/Extensions/DataGridView+Columns.swift` +- `TablePro/Views/Results/Extensions/DataGridView+Selection.swift` +- `TablePro/Views/Results/TableRowsController.swift` +- `TablePro/Models/Query/TableRows.swift` +- `TablePro/Models/Query/Row.swift` +- `TablePro/Core/Database/DatabaseManager.swift` +- `TablePro/Core/Services/Query/QueryExecutor.swift` +- `TablePro/Core/Services/Query/TableQueryBuilder.swift` +- `TablePro/Core/Services/Formatting/CellDisplayFormatter.swift` +- `TablePro/Models/Query/QueryTabState.swift` (PaginationState) +- `Plugins/TableProPluginKit/PluginQueryResult.swift` +- `Plugins/TableProPluginKit/PluginDatabaseDriver.swift` +- `Plugins/TableProPluginKit/PluginStreamTypes.swift` + +Reference codebases: +- Gridex `gridex/macos/Presentation/Views/DataGrid/AppKitDataGrid.swift` +- Gridex `gridex/macos/Presentation/Views/DataGrid/DataGridView.swift` +- Sequel-Ace `Sequel-Ace/Source/Other/CategoryAdditions/SPDataStorage.{h,m}` +- Sequel-Ace `Sequel-Ace/Source/Other/CategoryAdditions/SPNotLoaded.h` +- Sequel-Ace `Sequel-Ace/Source/Controllers/MainViewControllers/SPCustomQuery.m` (`updateResultStore:`, `QueryProgressHandler`) + +Cross-reference: `DATAGRID_PERFORMANCE_AUDIT.md` sections 0, 1, 2.2, 3.1, 3.2, 6, 7. + +--- + +## 0. Architecture comparison (data path only) + +| Aspect | TablePro (today) | Gridex | Sequel-Ace | +|---|---|---|---| +| Backing store | `TableRows` = `ContiguousArray` of `Row { id, values: [String?] }`, fully materialised in main-actor RAM. | `[[RowValue]]` materialised in `DataGridViewState`; UI also keeps a separate `[[String]] displayCache` of pre-formatted strings. | `SPDataStorage` wraps `SPMySQLStreamingResultStore`. Untouched rows are proxied from the streaming store; edits live in a parallel `NSPointerArray editedRows`. Never holds the full result in RAM in the UI layer. | +| Display value | Computed on-demand inside `tableView(_:viewFor:row:)` via `displayValue(forID:column:rawValue:columnType:)` (DataGridCoordinator.swift:266). Calls `CellDisplayFormatter.format` (DateFormatter / blob / `nsString.length` truncate) per cell during scroll. | Pre-computed once at load into `vm.displayCache: [[String]]`. Cell render only does an array index read (AppKitDataGrid.swift:319-324). | Truncated at storage layer. Edited rows hard-truncate at 150 chars (`SPDataStorage.m:189`); streaming preview goes through `SPMySQLResultStorePreviewAtRowAndColumn` with caller-supplied `previewLength` (typically 150). | +| Cache shape | `[RowID: [String?]]` dictionary keyed by RowID. Unbounded. (DataGridCoordinator.swift:17) | `[[String]]` index-aligned with `rows`. Bounded by row count (which is itself the page). | Pointer-array of edited rows + pass-through to streaming store. The "cache" is the streaming store itself, which buffers fixed memory. | +| Reverse lookup | `TableRows.index(of: RowID)` is a `for` loop over all rows (TableRows.swift:42). Called from `tableRowsIndex(forDisplayRow:)`, `row(withID:)`, `pruneDisplayCacheToAliveIDs`. | Indices match positionally; no reverse map needed because the cache is index-aligned. | Indices are positional; the streaming store owns the row index space and edited rows are pointer-array slots at the same index. | +| Row visual state | `rowVisualStateCache: [Int: RowVisualState]` rebuilt from scratch on every change (DataGridCoordinator.swift:534). | Two `Set` (`deletedRows`, `insertedRowIndices`) and one `Set` (`modifiedCells`) recomputed from `pendingChanges` only when the change set actually differs (AppKitDataGrid.swift:108-119). | Pending edits live in the pointer array; "is row edited" is `editedRows.pointerAt(index) != NULL` - O(1) per row. | +| Plugin boundary | `PluginQueryResult.rows: [[String?]]` - Codable, copied across the plugin boundary (PluginQueryResult.swift:6). One full-page allocation per fetch. | N/A (Gridex has no plugin boundary - adapters are in-process actors). | `SPMySQLStreamingResultStore` is the storage. Rows arrive incrementally over the socket; UI reads through `cellDataAtRow:column:` without ever owning the row array. | +| Page loading | SQL `LIMIT/OFFSET` (or `OFFSET … FETCH NEXT`) per page. Each page is a fresh round-trip; previous pages are discarded. (TableQueryBuilder.swift:221, QueryExecutor.swift:131, MainContentCoordinator+Pagination.swift:70). `PluginDatabaseDriver` exposes a streaming variant (`streamRows(query:)`) but it is wired only to export, not to the grid. | Page size 300 in-memory; pagination reloads the whole page. | True streaming. `SPCustomQuery.updateResultStore:` calls `[resultStore startDownload]` and the UI is updated via a poll timer while data still arrives (`SPCustomQuery.m:1149-1155`). | +| Concurrency | `@MainActor` everything. Format runs on main during `viewFor:row:` and during `preWarmDisplayCache(upTo:)` invoked synchronously from `updateNSView` (DataGridView.swift:188-194). | `Combine.debounce(0.1)` on `viewModel.objectWillChange`, snapshot copied to coordinator on main (AppKitDataGrid.swift:153-162). Formatting runs off-main inside the view model. | `pthread_mutex_t` and `@synchronized(self)` in `SPDataStorage`; UI reads from main while a worker thread fills the streaming store. | + +Net: TablePro is the only one of the three that formats during scroll, holds the entire page in RAM with an unbounded auxiliary dictionary cache, and runs an O(n) reverse lookup from `RowID` to index. + +--- + +## 1. Issue catalogue + +Severity: **CRIT** = visible scroll lag/freeze, **HIGH** = perf or correctness, **MED** = cleanup, **LOW** = polish. + +### D1 - On-demand `DateFormatter` / blob / `nsString.length` inside `viewFor:row:` + +- Severity: **CRIT** +- TablePro: `DataGridCoordinator.swift:266-283` (`displayValue(forID:column:rawValue:columnType:)`), invoked from `DataGridView+Columns.swift:42-47`. +- Why it lags: `viewFor:row:` is the AppKit hot path during scroll. On a cache miss it allocates a `String?` row cache, calls `CellDisplayFormatter.format` (which calls `DateFormattingService.format(dateString:)`, `BlobFormattingService.formatIfNeeded`, an `NSString` length check, and `sanitizedForCellDisplay`), then back-fills the row cache. First-paint cost is hidden because the run loop yields between cells, but during fast scroll AppKit re-asks for the same rows and the cache is dictionary-keyed by `RowID` (allocated UUID for inserted rows, enum payload otherwise) - every read walks a hash and an `Optional` unwrap chain. `CellDisplayFormatter.maxDisplayLength = 10_000` (CellDisplayFormatter.swift:13) is a 10K char cap; that is too generous for a cell that physically renders ~200 glyphs at the widest column. +- Reference patterns: Gridex `AppKitDataGrid.swift:319-324` only reads `vm.displayCache[row][col]`; formatting was done by the view model when rows landed. Sequel-Ace `SPDataStorage.m:189` returns a 150-char preview at the storage layer. +- Apple-correct equivalent: The format step belongs in the storage layer, not the view layer. Run `CellDisplayFormatter.format` on a background actor when a page lands, store the formatted string into the cache, and have `viewFor:row:` do nothing but `cache[row][col]`. Pre-truncate to ~300 glyphs (the practical width of a wide column on a 27" display at the smallest grid font) to keep `NSAttributedString` allocations bounded. Apple guidance: "Avoid expensive work in `tableView(_:viewFor:row:)`. Prepare data ahead of time." (`NSTableView` documentation, "Tips for displaying large numbers of rows".) + +### D2 - `TableRows.index(of:)` is O(n) + +- Severity: **CRIT** +- TablePro: `TableRows.swift:42-47`. Used by `tableRowsIndex(forDisplayRow:)` (DataGridCoordinator.swift:259), `row(withID:)` (TableRows.swift:50), `pruneDisplayCacheToAliveIDs()` (DataGridCoordinator.swift:329-338), `removeMissingIDsFromSortedIDs()` (DataGridCoordinator.swift:403-412). +- Why it lags: When the grid is sorted, `displayRow(at:)` does `tableRows.row(withID: sorted[displayIndex])` → `index(of:)` → linear scan. A 5K-row sorted page asks for 5K cell views during initial layout, each one paying O(n) to resolve its row. Net cost is O(n²) per layout. Same scan happens in `pruneDisplayCacheToAliveIDs()` (called on every `rowsRemoved` delta) which builds an O(n) `Set` of survivors and then filters the cache. +- Reference patterns: Sequel-Ace and Gridex don't have this problem because their primary key is the integer index. Sequel-Ace's `SPDataStorage` uses pointer-array slot equals row index. Gridex's `displayCache` is index-aligned with `rows`. +- Apple-correct equivalent: Maintain `private var indexByID: [RowID: Int]` in `TableRows`, kept in lockstep with the rows array. Update on `appendInsertedRow`, `insertInsertedRow`, `appendPage`, `removeIndices`, `replace(rows:)`. `index(of:)` becomes `indexByID[id]`. Use `Dictionary.reserveCapacity(rows.count)` after a full replace. Apple Swift documentation: "Dictionary lookup is O(1) on average; resizing when adding many keys is amortised by `reserveCapacity(_:)`" (`Dictionary` reference). + +### D3 - `displayCache` is unbounded `[RowID: [String?]]` + +- Severity: **HIGH** +- TablePro: `DataGridCoordinator.swift:17`, mutated in `displayValue(forID:column:rawValue:columnType:)` (line 282), `preWarmDisplayCache(upTo:)` (line 305), invalidation paths at lines 285-302, 340-345, 414. +- Why it lags: For a 100K-row paginated view (filter off, sort off, page = 100K because the user bumped the page-size setting) the cache holds 100K × column-count `String?` values. Each `String` is heap-allocated. There is no eviction; the cache is only cleared on `invalidateDisplayCache()` (full clear), `pruneDisplayCacheToAliveIDs()` (filter-into-new-dict copy), `releaseData()` (teardown). +- Reference patterns: Sequel-Ace doesn't keep a UI-side cache - the streaming store owns the bytes. Gridex's `displayCache` is bounded by the rows currently in the view model (page size 300). +- Apple-correct equivalent: `NSCache`, keyed by `displayIndex` (or compound `(pageGeneration, row)` so cache survives across pages of the same query). `NSCache` already implements purgeable-memory eviction under memory pressure (Apple `NSCache` reference: "When the system needs to free memory, it can begin removing cached objects"). Set `countLimit` to 5× the visible-rect row count plus a small padding. `NSCache` is thread-safe by default, which removes the "is the cache mutated off main?" worry from audit C6. + - Alternative for predictable footprint: a windowed `[String?]?` array sized to `rowCount`, only the visible-range slot populated. Beats `NSCache` for hit rate when scrolling continuously, loses on memory bound. Recommend `NSCache` because the win is bounding worst-case memory, not cache hit rate (the formatter is fast enough to recompute). + +### D4 - `rowVisualStateCache` rebuilt O(n) per edit + +- Severity: **HIGH** +- TablePro: `DataGridCoordinator.swift:533-577` (`rebuildVisualStateCache()`). Invoked from `applyInsertedRows`, `applyRemovedRows`, `applyDelta(.cellChanged…)`, `applyDelta(.cellsChanged…)`, `applyDelta(.rowsRemoved…)`, `updateNSView`. +- Why it lags: The guard at line 536 (`currentVersion != lastVisualStateCacheVersion`) only short-circuits when the change manager's version did not bump. Any edit bumps the version, so the entire cache is rebuilt. The rebuild iterates `changeManager.rowChanges` plus `insertedRowIndices`. For 5K pending edits this is 5K dictionary writes per single-cell edit. +- Reference patterns: Gridex `AppKitDataGrid.swift:108-120` uses two flat sets and recomputes only when `pendingChanged` is true. Sequel-Ace asks `editedRows.pointerAtIndex(row) != NULL` - O(1) per row, no auxiliary state. +- Apple-correct equivalent: Replace `[Int: RowVisualState]` with three primitive sets: + ```swift + struct RowVisualIndex { + var deleted: Set + var inserted: Set + var modifiedRows: Set + var modifiedColumnsByRow: [Int: Set] + } + ``` + On `applyDelta(.cellChanged(row, column))`: `index.modifiedRows.insert(row); index.modifiedColumnsByRow[row, default: []].insert(column)`. On `.rowsInserted(indices)`: `index.inserted.formUnion(indices)`. Cost is proportional to changed rows, not total rows. Drop the version-counter short-circuit. + +### D5 - Plugin boundary returns a full `[[String?]]` copy per page + +- Severity: **HIGH** +- TablePro: `PluginQueryResult.swift:6` (`public let rows: [[String?]]`). Created by every `PluginDatabaseDriver.execute(query:)` and `executeUserQuery(query:rowCap:parameters:)`. Consumed at `QueryExecutor.swift:131,153`. The plugin already exposes a streaming variant `streamRows(query:)` (PluginDatabaseDriver.swift:142) wired only to the export pipeline (`StreamingQueryExportDataSource`, `ExportDataSourceAdapter`, `QueryResultExportDataSource`), not to the grid. +- Why it lags: A 100K-row page builds a `[[String?]]` of 100K `Array>` instances inside the plugin process, then ships it across the Codable boundary, where the grid copies it into a `ContiguousArray`. Two allocations per row, one full graph traversal per page. Worse, every cell is `String?` regardless of width - a 4-byte int comes across as a heap-allocated `String("123")`. +- Reference patterns: Sequel-Ace `SPDataStorage` consumes rows from `SPMySQLStreamingResultStore` lazily; rows are decoded only when the UI asks for `cellDataAtRow:column:`. Sequel-Ace ships a `previewLength` parameter so the storage truncates the bytes before allocating the `NSString`. +- Apple-correct equivalent: Adopt the streaming protocol that the export layer already uses (`PluginStreamElement.header` / `.rows([PluginRow])`) for the grid path. The bridge across the plugin boundary becomes: + ```swift + public protocol PluginDatabaseDriver: AnyObject, Sendable { + func executeStreamingQuery( + _ query: String, + rowCap: Int?, + parameters: [String?]? + ) -> AsyncThrowingStream + } + ``` + with a default implementation that wraps `execute(query:)` for plugins that haven't been migrated. This is a `currentPluginKitVersion` bump (CLAUDE.md mandate). + - Concrete cell-width win: chunk size 1K rows. Header arrives first; the grid reconciles columns and starts rendering placeholders. As `.rows` chunks arrive, the storage layer (see §2) materialises rows incrementally and tells the table view to insert them via `insertRows(at:withAnimation:)`. Memory footprint is bounded by the storage layer's eviction policy, not by the page size. + - Codable cost: `PluginQueryResult` is `Codable` for cross-process plugin transport. The streaming variant must use the same encoding (each `PluginStreamElement` chunk is itself `Codable`). The chunked encoding cost is amortised across smaller payloads, so the steady-state cost is the same; the win is end-to-end latency (first-paint when the first 1K rows arrive vs when the full 100K-row page lands). + +### D6 - `preWarmDisplayCache(upTo:)` runs on main inside `updateNSView` + +- Severity: **HIGH** +- TablePro: `DataGridCoordinator.swift:305-327`, called from `DataGridView.swift:188-194` ("If we just got the first page, format `visibleRows + 5` rows synchronously"). +- Why it lags: `updateNSView` runs on main during a SwiftUI render pass. Pre-warming N rows × M columns means N×M `CellDisplayFormatter.format` calls - each potentially a `DateFormatter` invocation - before SwiftUI returns from the update. On wide tables (100 columns × 50 visible rows = 5000 format calls) the SwiftUI frame budget is gone before the table draws. +- Reference patterns: Gridex pushes formatting into the view model, off the SwiftUI update path. Sequel-Ace runs the streaming-store fill on a worker thread. +- Apple-correct equivalent: Move pre-warming into the storage layer (§2). When a page lands, dispatch to a background actor: + ```swift + actor DisplayCache { + func warm(rows: ContiguousArray, columnTypes: [ColumnType], formats: [ValueDisplayFormat?]) async -> ContiguousArray> { + var out = ContiguousArray>() + out.reserveCapacity(rows.count) + for row in rows { + var cached = ContiguousArray(repeating: nil, count: columnTypes.count) + for col in 0..(repeating: nil, count: columnCount)` once, and write into it in place. Even better: store `ContiguousArray>` index-aligned with `rows`, indexed by `Int` not `RowID`. Pair this with §D2's reverse map. + +### D8 - `displayCache.filter { aliveIDs.contains($0.key) }` allocates a new dict + +- Severity: **MED** +- TablePro: `DataGridCoordinator.swift:337` (`pruneDisplayCacheToAliveIDs`). `Dictionary.filter` returns a new dictionary, copying every kept entry. +- Apple-correct equivalent: In-place removal: + ```swift + let stale = Set(displayCache.keys).subtracting(aliveIDs) + for id in stale { displayCache.removeValue(forKey: id) } + ``` + Or, with the index-aligned cache from §D7, `cache.removeSubrange(deletedRange)`. `Array.removeSubrange` is in-place and amortised O(removed). + +### D9 - `tableRowsProvider()` closure called repeatedly inside loops + +- Severity: **MED** (audit C7) +- TablePro: `DataGridCoordinator.swift:259, 263, 397, 405`, `preWarmDisplayCache` line 305-327. Each call hits the SwiftUI binding indirection. +- Apple-correct equivalent: Capture the `TableRows` once at the start of the loop body. Cheap fix; mention here because the streaming-storage refactor will collapse this entirely (§2). + +### D10 - Pagination is `LIMIT/OFFSET`, not streaming + +- Severity: **HIGH** +- TablePro: `TableQueryBuilder.swift:221-226` (`buildPaginationClause`), `MainContentCoordinator+Pagination.swift:70-76` (`reloadCurrentPage()`), `QueryExecutor.swift:130-133`. Default page size 1000 (`PaginationState.defaultPageSize` = 1000, QueryTabState.swift:107). The grid does NOT use the existing `PluginDatabaseDriver.streamRows(query:)`. +- Why it costs: Each page change re-runs the SQL with a new `OFFSET`. For deep pagination (`OFFSET 100000`) Postgres and MySQL both scan-and-discard the offset rows. Worse, the user perceives the page change as a full reload (`Delta.fullReplace` → `tableView.reloadData()` at DataGridCoordinator.swift:243). There is no way to scroll past the bottom of a page into the next page; the user must click pagination controls. +- Reference patterns: Sequel-Ace's "show contents" view streams the full table via `SPMySQLStreamingResultStore` - there is no UI pagination. The streaming store reads rows from the wire as the user scrolls, and reuses the connection. `SPCustomQuery.m:1149-1155` shows the integration: open the store, kick off `[startDownload]`, let the UI poll for new rows via `initQueryLoadTimer`. `awaitDataDownloaded` blocks the worker thread until the store is full but the UI is responsive throughout. +- Apple-correct equivalent: Two-tier loading. + - **Window query**: keep `LIMIT/OFFSET` for the user's "go to page" controls (preserves existing UX). Use small windows (1K rows each) so the cost of `OFFSET` is bounded. + - **Streaming fill**: when the user scrolls to the bottom of the current window, kick off `executeStreamingQuery` for the next window in the background and merge with `tableView.beginUpdates() / insertRows(at:) / endUpdates()` as chunks arrive. Use `AsyncThrowingStream` (SE-0314) to consume chunks; cancel the stream on tab close, sort change, filter change, or schema change via the `Task` cancellation that's already wired into `MainContentCoordinator`. + - **Progress decoupling**: model the existing `PluginStreamElement.header` / `.rows([PluginRow])` envelope as the single transport for both export and grid (§D5). Collapse `executeUserQuery` into a `collect()` over the streaming variant for plugins that want the legacy interface. + - Apple references: `AsyncThrowingStream` (SE-0314), `Task { … }.cancel()` (Swift Concurrency), `NSTableView.beginUpdates()` (animatable batched mutations). + +### D11 - Settings observer fires `tableView.reloadData(forRowIndexes:columnIndexes:)` over the visible range on any data-format change + +- Severity: **MED** +- TablePro: `DataGridCoordinator.swift:139-173` - when `dateFormat`, `nullDisplay`, or `enableSmartValueDetection` changes, the coordinator clears the entire `displayCache` and reloads the visible range. Correct in spirit, but pairs with §D6 (formatter on main) to produce a hitch when settings change while a large table is open. +- Apple-correct equivalent: With the streaming storage in §2, settings change becomes "tell the storage layer the format changed", storage re-warms the visible window off-main, then signals the coordinator with the warmed slice. Same UX, no main-thread stall. + +--- + +## 2. Target architecture + +The target is a layered storage abstraction modelled on `SPDataStorage` (Sequel-Ace), with the formatting cache modelled on Gridex's `[[String]]` displayCache, and the transport modelled on the existing `PluginStreamElement` envelope. Three layers, each behind a Swift protocol with a clear ownership story. + +### 2.1 `DataGridStore` - streaming row storage (replaces `TableRows`) + +```swift +public enum CellState: Sendable, Equatable { + case notLoaded + case null + case loaded(String) +} + +public struct CellDisplay: Sendable, Equatable { + public let raw: CellState + public let formatted: String? + public let isTruncated: Bool +} + +public enum DataGridChange: Sendable { + case header(columns: [String], columnTypes: [ColumnType]) + case rowsAppended(range: Range) + case rowsReplaced(indices: IndexSet) + case rowsRemoved(indices: IndexSet) + case cellsChanged(positions: Set) + case streamingFinished(totalCount: Int) + case streamingFailed(error: Error) +} + +public protocol DataGridStore: Sendable { + var rowCount: Int { get async } + var columnCount: Int { get async } + var columns: [String] { get async } + var columnTypes: [ColumnType] { get async } + + func cellRaw(at row: Int, column: Int) async -> CellState + func cellDisplay(at row: Int, column: Int) async -> CellDisplay + func cellDisplay(at row: Int, column: Int, previewLength: Int) async -> CellDisplay + + func prefetchRows(in range: Range) async + func cancelPrefetch(in range: Range) async + + func replaceCell(at row: Int, column: Int, with value: String?) async + func appendInsertedRow(values: [String?]) async -> Int + func remove(rows: IndexSet) async + + var changes: AsyncStream { get } +} +``` + +Why this shape: +- `cellDisplay` is the hot path. Cell render reads it directly. The default implementation pre-formats on warm-in (background actor) so the read is O(1) dictionary lookup or array index. +- `previewLength` parameter mirrors `SPDataStoragePreviewAtRowAndColumn(_:_:_:_)` (Sequel-Ace `SPDataStorage.h:117-123`). The grid passes 300 (truncate at view layer); export passes `Int.max` (full string). +- `CellState.notLoaded` is the Swift translation of Sequel-Ace's `SPNotLoaded` sentinel (`SPNotLoaded.h:31-42`). Distinguishing "not yet streamed" from `null` matters during the streaming window - placeholder cells display "…" with an italic dimmed style, never "NULL". +- `prefetchRows(in:)` lets the table view ask the store to warm display strings for the visible-rect-plus-margin. Implementation kicks off a `Task` on a background actor and emits `.cellsChanged` when the slot is ready. +- `changes` is an `AsyncStream` (SE-0314 `AsyncStream`). The coordinator subscribes once on attach, drives `tableView.beginUpdates()/insertRows(at:)/endUpdates()` from the stream. `AsyncStream` (vs `AsyncThrowingStream`) because errors travel as `.streamingFailed` so the coordinator can render an inline error row instead of unwinding. + +Concrete implementation sketch - `StreamingDataGridStore`: + +```swift +public actor StreamingDataGridStore: DataGridStore { + private var columns: [String] = [] + private var columnTypes: [ColumnType] = [] + private var rows: ContiguousArray = [] + private var indexByID: [RowID: Int] = [:] + private let displayCache = NSCache() + private var streamTask: Task? + private let continuation: AsyncStream.Continuation + + public nonisolated let changes: AsyncStream + + public init(driver: PluginDatabaseDriver, query: String, rowCap: Int?, parameters: [String?]?, settings: DataGridSettings) { + let (stream, continuation) = AsyncStream.makeStream() + self.changes = stream + self.continuation = continuation + displayCache.countLimit = max(2_000, settings.streamingCacheCountLimit) + Task { await self.start(driver: driver, query: query, rowCap: rowCap, parameters: parameters) } + } + + private func start(driver: PluginDatabaseDriver, query: String, rowCap: Int?, parameters: [String?]?) async { + do { + for try await element in driver.streamRows(query: query) { + if Task.isCancelled { return } + switch element { + case .header(let header): + columns = header.columns + columnTypes = ColumnType.parseAll(header.columnTypeNames) + continuation.yield(.header(columns: columns, columnTypes: columnTypes)) + case .rows(let chunk): + let firstIndex = rows.count + rows.reserveCapacity(rows.count + chunk.count) + for values in chunk { + let id = RowID.existing(rows.count) + rows.append(RowSlot(id: id, values: ContiguousArray(values))) + indexByID[id] = rows.count - 1 + } + let lastIndex = rows.count - 1 + continuation.yield(.rowsAppended(range: firstIndex..` because `NSCache` is documented as thread-safe ("It also incorporates various auto-removal policies, which ensure that it does not use too much of the system's memory" - `NSCache` reference). Keys are boxed `Int` (row index). Values are `NSArray` of `NSString`/`NSNull`, sized to `columnCount`. `countLimit` defaults to 5× the visible-row count from settings (no hard cap; bound by reachable memory pressure). +- `OSAllocatedUnfairLock` (Apple `os` framework, macOS 13+) is the right primitive for a non-actor variant if profiling shows actor-hop cost dominates. For now `actor` is the simpler, correct default; document `OSAllocatedUnfairLock` as the escape hatch in the rewrite plan. +- `AsyncStream.makeStream()` was added in Swift 5.9 / macOS 14, which is TablePro's deployment target - no back-compat issue. +- `Task.isCancelled` honours the structured concurrency cancellation that already exists when a tab closes or the user re-runs a query. + +### 2.2 `RowVisualIndex` - incremental edit-state tracking (replaces `rowVisualStateCache`) + +```swift +@MainActor +final class RowVisualIndex { + private(set) var deleted: Set = [] + private(set) var inserted: Set = [] + private(set) var modifiedColumnsByRow: [Int: Set] = [:] + + func apply(_ change: ChangeManagerDelta) { + switch change { + case .cellEdited(let row, let column): + modifiedColumnsByRow[row, default: []].insert(column) + case .rowDeleted(let row): + deleted.insert(row) + case .rowInserted(let row): + inserted.insert(row) + case .changesCommitted, .changesDiscarded: + deleted.removeAll(keepingCapacity: true) + inserted.removeAll(keepingCapacity: true) + modifiedColumnsByRow.removeAll(keepingCapacity: true) + } + } + + func state(for row: Int) -> RowVisualState { + RowVisualState( + isDeleted: deleted.contains(row), + isInserted: inserted.contains(row), + modifiedColumns: modifiedColumnsByRow[row] ?? [] + ) + } +} +``` + +Cost is O(1) per delta, O(1) per cell render. Replaces DataGridCoordinator.swift:533-577 entirely. The change manager already emits per-cell deltas; the rebuild-on-version pattern goes away. + +### 2.3 `CellDisplayWarmer` - off-main formatting (replaces `preWarmDisplayCache`) + +```swift +public actor CellDisplayWarmer { + public func warm( + chunk: [PluginRow], + columnTypes: [ColumnType], + displayFormats: [ValueDisplayFormat?], + previewLength: Int + ) -> ContiguousArray> { + var out = ContiguousArray>() + out.reserveCapacity(chunk.count) + for values in chunk { + var cached = ContiguousArray(repeating: nil, count: columnTypes.count) + let upper = min(values.count, columnTypes.count) + for col in 0..? + + func attach(to tableView: NSTableView) { + self.tableView = tableView + streamObserver = Task { @MainActor [weak self] in + guard let self else { return } + for await change in await self.store.changes { + self.applyStreamChange(change) + } + } + } + + func numberOfRows(in tableView: NSTableView) -> Int { + cachedRowCount + } + + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + guard let column = resolveColumn(tableColumn) else { return nil } + let display = store.cachedCellDisplay(row: row, column: column) + let cell = cellRegistry.dequeueCell(of: kind, in: tableView) + cell.configure(content: .init(text: display.formatted ?? ""), state: cellState(row: row, column: column)) + return cell + } +} +``` + +Key shifts: +- `viewFor:row:` reads the cache only. `store.cachedCellDisplay(row:column:)` is a synchronous nonisolated read of the warmed slot (or returns "…" placeholder if the slot is `notLoaded`). +- `applyStreamChange(_:)` translates `DataGridChange` into `tableView.insertRows(at:withAnimation:)`, `tableView.reloadData(forRowIndexes:columnIndexes:)`, etc. No more `Delta` struct duplication between `TableRowsController` and the coordinator. +- `displayCache` lives entirely in the store, behind `NSCache`. The coordinator never touches it. +- `rowVisualStateCache` becomes `RowVisualIndex`, applied on each `ChangeManagerDelta`. No version short-circuit. + +### 2.5 Plugin boundary + +Add to `PluginDatabaseDriver`: + +```swift +public extension PluginDatabaseDriver { + func executeStreamingQuery( + _ query: String, + rowCap: Int?, + parameters: [String?]? + ) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + Task { + do { + let result: PluginQueryResult + if let parameters { + result = try await self.executeParameterized(query: query, parameters: parameters) + } else { + result = try await self.execute(query: query) + } + continuation.yield(.header(.init( + columns: result.columns, + columnTypeNames: result.columnTypeNames, + estimatedRowCount: result.rows.count + ))) + let chunkSize = 1_000 + var startIndex = 0 + while startIndex < result.rows.count { + if Task.isCancelled { continuation.finish(); return } + let endIndex = min(startIndex + chunkSize, result.rows.count) + continuation.yield(.rows(Array(result.rows[startIndex..>` keyed by display index. + - D8: in-place dict pruning. + - D4: `RowVisualIndex` with incremental updates. + - These four ship without a plugin ABI change; they are the highest-ROI items. + +2. **Sprint 2 (architecture)** + - D5, D6, D10: introduce `DataGridStore` protocol + `StreamingDataGridStore` actor. + - Bump `currentPluginKitVersion`. Add default `executeStreamingQuery` extension. Migrate built-in PostgreSQL and MySQL plugins to native streaming. + - Move formatting off-main into `CellDisplayWarmer`. Relax `CellDisplayFormatter` from `@MainActor` to `nonisolated`. + - D11 falls out: settings-change rewarms the visible window via the warmer, off-main. + +3. **Sprint 3 (cleanup)** + - Collapse `TableRowsController` into the coordinator (it's now redundant; the coordinator subscribes to `store.changes` directly). + - Replace `Delta` enum with `DataGridChange`. + - D9: closure-call elision is automatic once `TableRows` is gone. + +--- + +## 4. Apple references + +- `AsyncStream`, `AsyncThrowingStream`: SE-0314 ("AsyncSequence"), Swift Evolution. Used for `changes` and `executeStreamingQuery`. +- `NSCache` thread-safety and auto-eviction: Apple Foundation Reference, "NSCache". Used for `displayCache`. +- `OSAllocatedUnfairLock`: Apple `os` framework, macOS 13+, "OSAllocatedUnfairLock". Documented escape hatch when actor-hop cost is unacceptable. +- `Sendable`: SE-0302 ("Sendable and `@Sendable` closures"). All transfer types in §2.1 are `Sendable`. +- `NSTableView.beginUpdates()` / `insertRows(at:withAnimation:)`: AppKit Reference, "NSTableView". Animatable batched mutations driven by `DataGridChange`. +- `Task.isCancelled`, `Task.yield()`: Swift Concurrency, "Cancellation". Honoured throughout the streaming pipeline so tab close / re-run cleanly tears down in-flight fetches. +- `Date.FormatStyle`: macOS 12+, replaces `DateFormatter` for new format paths (audit N3). Optional follow-up - `DateFormattingService` already caches `DateFormatter` instances, so the win is style consistency, not raw perf. + +--- + +## 5. Concrete success criteria + +- Scrolling a 50K-row table at 120 fps with 50 visible rows × 30 columns shows zero `CellDisplayFormatter.format` invocations during scroll (verified via Instruments, Time Profiler). +- `TableRows.index(of:)` does not appear in the top-100 hottest functions (verified via Instruments). +- `displayCache` memory bounded by `NSCache.countLimit × columnCount × averageStringLength`, observable in Instruments Allocations. +- A `SELECT * FROM table` against a 1M-row table renders the first 1K rows in <500ms (first-paint latency), regardless of total table size, because the streaming store starts emitting before the query finishes. +- Filter / sort / settings changes never block the main thread for >16ms (verified via Hangs Instrument, "Hangs (All)"). + +--- + +## 6. Open questions + +1. The export pipeline already consumes `streamRows(query:)`. Confirm that the chunk size used by built-in plugins (PostgreSQL, MySQL) is small enough to stream cleanly into the grid, or recommend a chunk-size knob in `PluginCapabilities`. +2. `RowID.inserted(UUID())` allocates a UUID per inserted row. With the `indexByID` map + position-keyed `displayCache`, the UUID becomes pure equality identity for inserted rows - confirm no other code path relies on the UUID for diffing or persistence. +3. Sequel-Ace's `SPNotLoaded` is a singleton; the Swift translation `CellState.notLoaded` is a stack value. Confirm that the cell renderer can branch on `CellState` without re-allocating per cell render. (It can - enum cases without payload are tag-only.) +4. The coordinator currently depends on `tableRowsProvider: () -> TableRows` and `tableRowsMutator: ((inout TableRows) -> Void) -> Void` closures threaded through SwiftUI. Replace with `let store: any DataGridStore` injected via the SwiftUI environment; confirm that hot-reload (`updateNSView`) of the store reference works correctly under SwiftUI's identity rules. diff --git a/docs/refactor/datagrid-native-rewrite/03-nstableview-api.md b/docs/refactor/datagrid-native-rewrite/03-nstableview-api.md new file mode 100644 index 000000000..9c68f9268 --- /dev/null +++ b/docs/refactor/datagrid-native-rewrite/03-nstableview-api.md @@ -0,0 +1,519 @@ +# 03 - NSTableView API Correctness Audit + +Scope: every place TablePro reimplements behavior that AppKit's `NSTableView` already provides. For each finding: TablePro file/line, what is being reimplemented, the native API to use, and the canonical Apple reference (developer.apple.com URL or AppKit header). All recommendations are anchored against the Sequel-Ace and Gridex precedents in this repo. + +View-based vs. cell-based decision: TablePro is view-based and that is correct for a fully editable grid with mixed accessory views (FK arrows, chevrons, NULL/DEFAULT placeholders). Apple's "Cell-Based and View-Based Table Views" guidance (Apple TableView Programming Guide, "View-Based vs. Cell-Based Table Views") explicitly recommends view-based when cells contain controls, custom drawing, or per-row accessibility. Sequel-Ace runs cell-based (`SPCopyTable` is an `NSTableView` subclass with `NSCell`-style drawing) but Sequel-Ace pre-dates view-based tables and pays for it with a custom field editor and custom drag/drop plumbing we should not adopt. Keep view-based. + +References used throughout: +- `Frameworks/AppKit/NSTableView.h` +- developer.apple.com/documentation/appkit/nstableview +- developer.apple.com/documentation/appkit/nstablecolumn +- TablePlanning Programming Guide: "Sorting" and "View-Based Table Views" (TableView Programming Guide for Mac) +- AppKit Release Notes (10.7+ view-based; 10.5+ sort descriptor; 10.10+ row animations; 11+ `style`) + +--- + +## T1 - Column persistence: replace custom layout file with `autosaveName` + `autosaveTableColumns` + +**Reimplemented in TablePro**: +- `TablePro/Views/Results/DataGridCoordinator.swift:42` `savedColumnLayout(binding:)` +- `TablePro/Views/Results/DataGridCoordinator.swift:56` `captureColumnLayout()` +- `TablePro/Views/Results/DataGridCoordinator.swift:79` `persistColumnLayoutToStorage()` +- `TablePro/Views/Results/DataGridView.swift:365` `dismantleNSView` calls `persistColumnLayoutToStorage()` +- `TablePro/Views/Results/DataGridView.swift:218` `savedLayout` round-trip on every `updateNSView` +- `TablePro/Views/Results/DataGridColumnPool.swift:27` `reconcile(... savedLayout: ColumnLayoutState?, ...)` rebuilds `columnWidths`, `columnOrder`, `hiddenColumns` from a custom `ColumnLayoutState` written via `FileColumnLayoutPersister` + +What it reimplements: width persistence, visible column order persistence, hidden-column persistence, and re-application of all three when the table re-attaches. AppKit already does this on the table view itself. + +**Native API**: +``` +tableView.autosaveName = "TableProDataGrid.." +tableView.autosaveTableColumns = true +``` +- `NSTableView.autosaveName: NSTableView.AutosaveName?` persists per-column **width**, **visibility (`isHidden`)**, and **display order** under `NSTableView Columns ` in `NSUserDefaults`. +- `NSTableView.autosaveTableColumns: Bool` toggles the persistence. +- AppKit calls `restoreState` automatically when the table is loaded with an autosave name set; persistence happens automatically on column reorder/resize/hide. +- For per-table-name layouts, set `autosaveName` after `tableName` is known. For the initial `nil` state (no real table, e.g. ad-hoc query results), leave `autosaveName = nil` - autosave silently no-ops. + +Apple references: +- developer.apple.com/documentation/appkit/nstableview/autosavename +- developer.apple.com/documentation/appkit/nstableview/autosavetablecolumns +- `Frameworks/AppKit/NSTableView.h` → `@property (copy, nullable) NSTableView.AutosaveName autosaveName;` + +Sequel-Ace precedent: every `` element in `Sequel-Ace/Source/Interfaces/DBView.xib`, `QueryFavoriteManager.xib`, `BundleEditor.xib`, etc. ships with `autosaveName="..."` - Sequel-Ace never hand-rolls a column layout file. + +Migration path: +1. Set `tableView.autosaveName = makeAutosaveName(connectionId:tableName:tabType:)`. For `tabType == .query` use a stable per-query-tab key; for `tabType == .table` use `"TablePro.\(connectionId).\(tableName)"`. +2. Delete `FileColumnLayoutPersister`, `ColumnLayoutState.columnWidths`/`columnOrder`/`hiddenColumns`, `captureColumnLayout()`, `persistColumnLayoutToStorage()`, `savedColumnLayout(binding:)`, the `onColumnLayoutDidChange` callback, and the `@Binding var columnLayout` on `DataGridView`. +3. `DataGridColumnPool.reconcile` no longer takes `savedLayout`; it just creates/configures the columns and lets AppKit restore state. +4. One-time migration: read the legacy `ColumnLayoutState` file and write its widths/order into `UserDefaults` under the AppKit autosave key (`NSTableView Columns `) on first launch, then delete the file. AppKit's stored format is a flat dictionary keyed by column identifier - straightforward to translate. + +--- + +## T2 - Sort: replace `SortableHeaderView` mouseDown with `sortDescriptorPrototype` + `tableView(_:sortDescriptorsDidChange:)` + +**Reimplemented in TablePro**: +- `TablePro/Views/Results/SortableHeaderView.swift:207` whole `mouseDown(with:)` override that hit-tests resize zone, suppresses drag, reads `event.modifierFlags.shift`, runs `HeaderSortCycle.nextTransition`, mutates `coordinator.currentSortState`, and calls `coordinator.delegate?.dataGridSort(...)`. +- `TablePro/Views/Results/SortableHeaderView.swift:158` `updateSortIndicators(state:schema:)` driving custom `SortableHeaderCell.sortDirection`/`sortPriority`. +- `TablePro/Views/Results/SortableHeaderCell.swift:32` whole `drawInterior` + `drawSortIndicator` no-op override. +- `TablePro/Views/Results/DataGridView.swift:264` `syncSortDescriptors(...)` writes a single-element `tableView.sortDescriptors` and toggles `highlightedTableColumn`. +- `TablePro/Views/Results/DataGridColumnPool.swift:211` already sets `sortDescriptorPrototype = NSSortDescriptor(key: name, ascending: true)`, but the prototype is never honored because the click is intercepted before AppKit's standard handler runs. + +What it reimplements: the entire `NSTableView` sort-by-header-click flow. AppKit already does click detection, modifier handling for multi-sort, animated indicator drawing, and accessibility. + +**Native API**: +``` +column.sortDescriptorPrototype = NSSortDescriptor(key: column.identifier.rawValue, ascending: true) +// in delegate: +func tableView(_ tableView: NSTableView, sortDescriptorsDidChange oldDescriptors: [NSSortDescriptor]) { + let new = tableView.sortDescriptors + delegate?.dataGridApplySortDescriptors(new) +} +``` +- AppKit semantics (documented in TableView Programming Guide → "Sorting"): + - Click on header column with prototype → AppKit appends or replaces the prototype in `tableView.sortDescriptors`, flipping `ascending` if the same column is clicked again. + - **Shift-click** on a column with a prototype → AppKit appends instead of replacing, producing multi-key sort. This is the exact behavior `HeaderSortCycle.multiSortTransition` reimplements by hand. + - The single sort-cycle "ascending → descending → cleared" is **not** native - AppKit cycles ascending ↔ descending only. The "third click clears" UX in `HeaderSortCycle.singleSortTransition` is a TablePro design choice; it can be re-added in the delegate by inspecting `oldDescriptors` vs the new array. + - The header cell's sort triangle and priority number are drawn by `NSTableHeaderCell` automatically when `tableView.indicatorImage(in:)` is left at default. Override `tableView(_:didClick:)` only if you want **non-sort** column-click side effects. + +Apple references: +- developer.apple.com/documentation/appkit/nstablecolumn/sortdescriptorprototype +- developer.apple.com/documentation/appkit/nstableviewdatasource/tableview(_:sortdescriptorsdidchange:) +- TableView Programming Guide → "Sorting Table Views" +- `Frameworks/AppKit/NSTableView.h` → `@property (copy) NSArray *sortDescriptors;` + +Sequel-Ace precedent: `Sequel-Ace/Source/Controllers/SubviewControllers/SPProcessListController.m:762` `tableView:sortDescriptorsDidChange:` is the entire sort handler; the XIB defines `` on each column. No mouse handling, no header subclass. + +**Migration: descriptor flow and multi-column sort**: + +1. In `DataGridColumnPool.configureColumn`, keep the existing `sortDescriptorPrototype = NSSortDescriptor(key: name, ascending: true)` line; remove the `SortableHeaderCell` swap (use stock `NSTableHeaderCell` so AppKit draws the indicator triangle). +2. Delete `SortableHeaderView` entirely; restore `tableView.headerView = NSTableHeaderView()`. +3. In `TableViewCoordinator`, implement: + ``` + func tableView(_ tableView: NSTableView, sortDescriptorsDidChange oldDescriptors: [NSSortDescriptor]) { + let descriptors = tableView.sortDescriptors + let sortColumns = descriptors.compactMap { descriptor -> SortColumn? in + guard let key = descriptor.key, + let columnIndex = identitySchema.dataIndex(forColumnName: key) + else { return nil } + return SortColumn( + columnIndex: columnIndex, + direction: descriptor.ascending ? .ascending : .descending + ) + } + var newState = SortState(); newState.columns = sortColumns + delegate?.dataGridApplySortState(newState) + } + ``` +4. `DataGridView.syncSortDescriptors` becomes a one-way push: convert the bound `SortState` to `[NSSortDescriptor]` and assign `tableView.sortDescriptors`. AppKit handles redraw. +5. Multi-column sort now requires no special code: shift-click is built into `NSTableView`. The "shift-click third time removes" behavior in `HeaderSortCycle.multiSortTransition` (lines 31–58) can be re-added by post-filtering `descriptors` in `sortDescriptorsDidChange` - strip duplicates whose direction was flipped by the user, etc. Since the descriptor diff (`oldDescriptors` → `tableView.sortDescriptors`) tells you exactly which column the click changed, the cycle logic is local to that delegate call, ~10 lines, and doesn't need its own type. +6. The "sort indicator priority number ≥ 2" UI in `SortableHeaderCell` (lines 49–60) is replaced by AppKit's stock numbered triangles - `NSTableHeaderCell.drawSortIndicator(withFrame:in:ascending:priority:)` already does this. +7. The `currentSortState` mirror on the coordinator becomes redundant - `tableView.sortDescriptors` is the single source of truth. + +Net deletions if T2 + indicator simplification land: `SortableHeaderView.swift` (288 lines), `SortableHeaderCell.swift` (182 lines), `HeaderSortCycle` enum and `HeaderSortTransition`, `currentSortState` on `TableViewCoordinator`. The custom resize-cursor zone in `SortableHeaderView.resetCursorRects`/`mouseMoved` (lines 103–155) is also dead weight: `NSTableHeaderView` already manages resize cursors via `NSTableView.resizeColumn(...)` infrastructure when `column.resizingMask.contains(.userResizingMask)` (which `DataGridColumnPool` already sets). + +--- + +## T3 - Field editor: replace `CellOverlayEditor` borderless `NSPanel` with the standard field editor flow + `NSTextView` field editor for multi-line cells + +**Reimplemented in TablePro**: +- `TablePro/Views/Results/CellOverlayEditor.swift:31` `show(in:row:column:columnIndex:value:)` - builds a borderless `NSPanel` (lines 97–115) containing an `NSScrollView` + custom `OverlayTextView`, places it in **screen** coordinates (lines 60–66 use `convertToScreen`), wires `onCommit`/`onTabNavigation`, observes `NSView.boundsDidChangeNotification` and `NSTableView.columnDidResizeNotification` to dismiss. +- `TablePro/Views/Results/CellOverlayEditor.swift:148` `dismiss(commit:)` plus the `CellOverlayPanel.resignKey` hook to simulate commit-on-blur. +- `TablePro/Views/Results/CellOverlayEditor.swift:180` `textView(_:doCommandBy:)` reroutes Return/Esc/Tab/Backtab. +- `TablePro/Views/Results/Extensions/DataGridView+Editing.swift:78` `showOverlayEditor(...)` invoked from `tableView(_:shouldEdit:row:)` whenever the value contains a line break - diverting the standard `editColumn:row:with:select:` path entirely. +- `TablePro/Views/Results/KeyHandlingTableView.swift:209` `insertNewline(_:)` calls `showOverlayEditor` instead of `editColumn:row:with:select:` when the cell value contains a line break. + +What it reimplements: +1. The window/positioning/dismiss machinery that AppKit's field-editor system already provides. +2. Multi-line text entry in a single cell - but using a side-channel panel rather than the cell's own field editor. +3. Tab/Backtab navigation across cells (already supported by `NSTextField` + `NSTableView`). +4. Commit-on-blur (already provided by `NSControlTextEditingDelegate`'s `controlTextDidEndEditing`). + +**Native API** (single-line, the common case): +``` +tableView.editColumn(columnIndex, row: rowIndex, with: nil, select: true) +// commit and tab navigation flow through the standard delegate: +// func control(_ control: NSControl, textShouldEndEditing fieldEditor: NSText) -> Bool +// func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool +``` +- `NSTableView.editColumn(_:row:with:select:)` already hosts the cell's `NSTextField` field editor inline at the cell rect, scrolls the row to visible, focuses the editor, and routes commit through `controlTextDidEndEditing(_:)`. Apple: developer.apple.com/documentation/appkit/nstableview/editcolumn(_:row:with:select:). +- The cell text field is supplied by `NSTableCellView.textField`, which TablePro already uses (`DataGridBaseCellView.textField`). + +**Native API** (multi-line cells, the case `CellOverlayEditor` exists for): + +Use a custom **field editor** returned by `NSWindowDelegate.windowWillReturnFieldEditor(_:to:)`. This is the documented Apple pattern for replacing the default single-line `NSTextView` field editor with a multi-line one for specific controls, without needing a separate window. + +``` +// On the window delegate (or on EditorWindow itself): +func windowWillReturnFieldEditor(_ sender: NSWindow, to client: Any?) -> Any? { + guard let textField = client as? CellTextField, + textField.usesMultilineFieldEditor else { return nil } + return MultilineFieldEditor.shared // a long-lived NSTextView with field-editor mode +} + +final class MultilineFieldEditor: NSTextView { + static let shared: MultilineFieldEditor = { + let tv = MultilineFieldEditor(frame: .zero) + tv.isFieldEditor = true + tv.allowsUndo = true + tv.isRichText = false + tv.usesFontPanel = false + tv.importsGraphics = false + tv.isHorizontallyResizable = false + tv.isVerticallyResizable = true + tv.textContainer?.widthTracksTextView = true + return tv + }() +} +``` + +Apple references: +- developer.apple.com/documentation/appkit/nswindowdelegate/windowwillreturnfieldeditor(_:to:) +- developer.apple.com/documentation/appkit/nstextview/isfieldeditor +- TextEditing Programming Guide → "Using a Field Editor", section "Replacing the default field editor" +- `Frameworks/AppKit/NSWindow.h` → `- (nullable id)windowWillReturnFieldEditor:(NSWindow *)sender toObject:(nullable id)client;` + +Sequel-Ace precedent: `Sequel-Ace/Source/Views/TextViews/SPTextView.{h,m}` is exactly this pattern - a long-lived multi-line `NSTextView` returned as the field editor for SQL editing controls. The control delegate's `control(_:textView:doCommandBy:)` (TablePro already implements this in `DataGridView+Editing.swift:179`) handles Return/Esc/Tab; Option-Return for newline is one extra `commandSelector` check. Sequel-Ace references for cell editing are at `SPCopyTable.m:1192/1210/1260/1292/1306/1318` - note that every navigation call uses `[self editColumn:column row:row withEvent:nil select:YES]`, never a side-channel panel. + +**Migration**: +1. Add `usesMultilineFieldEditor: Bool` to `CellTextField`. Set it in `tableView(_:viewFor:row:)` (or `prepareForReuse`) when the cell value `containsLineBreak`. +2. Make `EditorWindow` (or whichever `NSWindow` hosts the table) implement `windowWillReturnFieldEditor(_:to:)` and return the shared `MultilineFieldEditor` for those cells. +3. Delete `CellOverlayEditor.swift` (243 lines), `CellOverlayPanel`, `OverlayTextView`. Delete `showOverlayEditor`, `commitOverlayEdit`, `handleOverlayTabNavigation`, the `.needsOverlayEditor` case in `InlineEditEligibility`, and the `KeyHandlingTableView.insertNewline` branch that invokes the overlay. +4. In `control(_:textView:doCommandBy:)` (`DataGridView+Editing.swift:179`): + - `insertNewline` → if Option held (`NSApp.currentEvent?.modifierFlags.contains(.option) == true`), `textView.insertNewlineIgnoringFieldEditor(nil)` and return `true`; otherwise commit and return `false` (let AppKit advance). + - `cancelOperation` already routes correctly through `isEscapeCancelling`. + - `insertTab` / `insertBacktab` already work - the existing implementation is correct. +5. The "dismiss on column resize" and "dismiss on scroll" observers in `CellOverlayEditor.show(...)` (lines 125–145) become unnecessary: AppKit's field editor lives inside the cell view, so it follows resize/scroll for free. + +Caveat: the standard field editor commits on blur. Some users want "Return commits, Esc cancels, click-outside also commits" - that's already the default. The current overlay editor's "click outside cancels" semantics (`onResignKey { dismiss(commit: true) }` at line 116) match commit-on-blur, so no behavioral change. + +--- + +## T4 - Split single class implementing 4 protocols into separate dataSource, delegate, and field-editor delegate + +**Reimplemented in TablePro**: +`TablePro/Views/Results/DataGridCoordinator.swift:8` declares +``` +final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewDataSource, + NSControlTextEditingDelegate, NSTextFieldDelegate, NSMenuDelegate +``` +and the class accumulates ~600 lines plus extension files for cells/sort/columns/editing/selection. There is also a `DataGridCellAccessoryDelegate` adoption at line 595. + +What it reimplements: nothing technically - but it conflates four roles AppKit treats as separate. Apple's TableView Programming Guide says: "The data source and delegate are typically separate objects... Splitting them keeps each responsibility focused and makes it easier to swap implementations." (TableView Programming Guide → "Setting Up a Table View" → "Data Source and Delegate".) + +**Native split**: +- `final class DataGridDataSource: NSObject, NSTableViewDataSource` - owns `numberOfRows(in:)`, `tableView(_:pasteboardWriterForRow:)`, drag/drop validate/accept, `tableView(_:sortDescriptorsDidChange:)` (technically a data-source method per the docs). +- `final class DataGridDelegate: NSObject, NSTableViewDelegate, NSMenuDelegate` - owns `tableView(_:viewFor:row:)`, `tableView(_:rowViewForRow:)`, `tableView(_:shouldEdit:row:)`, `tableView(_:sizeToFitWidthOfColumn:)`, header context menu (`menuNeedsUpdate`), selection callbacks. +- `final class DataGridFieldEditorController: NSObject, NSControlTextEditingDelegate, NSTextFieldDelegate` - owns `control(_:textShouldEndEditing:)`, `control(_:textView:doCommandBy:)`, plus the multi-line field editor vending if T3 lands. +- A small `DataGridContext` (or keep `TableViewCoordinator` as the composition root) holds shared state: `tableRowsProvider`, `changeManager`, `identitySchema`, `displayCache` reference, etc. The three role objects each take a context reference. + +This makes the file-length warnings under control (1200 line warning, 1800 error in `.swiftlint.yml`) and lets the dataSource be reused for non-AppKit scenarios (e.g. unit tests that drive `numberOfRows(in:)`/`pasteboardWriterForRow:` directly). + +Apple references: +- developer.apple.com/documentation/appkit/nstableviewdatasource +- developer.apple.com/documentation/appkit/nstableviewdelegate +- developer.apple.com/documentation/appkit/nscontroltexteditingdelegate +- TableView Programming Guide → "Data Source and Delegate" + +Sequel-Ace precedent: `SPTableContent` is the data source; `SPCopyTable` (the table itself) plus `SPTableContent` share delegate methods, but cell editing/field-editor handling lives on a separate "table content" controller and the `SPFieldEditorController` class (`Sequel-Ace/Source/Controllers/SubviewControllers/SPFieldEditorController.{h,m}`). + +Migration: this is the only finding in this section that does not change behavior. Defer until after T1–T3 land - those reduce the surface area of `TableViewCoordinator` first. + +--- + +## T5 - Adopt `tableView(_:typeSelectStringFor:row:)` for free incremental search + +**Reimplemented in TablePro**: nothing - TablePro currently has no incremental search. AppKit gives it for free if you implement one delegate method. + +**Native API**: +``` +func tableView( + _ tableView: NSTableView, + typeSelectStringFor tableColumn: NSTableColumn?, + row: Int +) -> String? { + guard let tableColumn, + let columnIndex = identitySchema.dataIndex(from: tableColumn.identifier), + let row = displayRow(at: row), + columnIndex < row.values.count + else { return nil } + return row.values[columnIndex] +} +``` +- AppKit picks up keystrokes on the table view, debounces them into a search prefix, and walks rows via this delegate to find the next match. Scrolls the matched row into view automatically. +- `NSTableView.typeSelectMatching(searchString:)`, `tableView(_:nextTypeSelectMatchFromRow:toRow:for:)`, and `tableView(_:shouldTypeSelectFor:withCurrentSearchString:)` give finer control if needed. + +Apple references: +- developer.apple.com/documentation/appkit/nstableviewdelegate/tableview(_:typeselectstringfor:row:) +- developer.apple.com/documentation/appkit/nstableviewdelegate/tableview(_:nexttypeselectmatchfromrow:torow:for:) +- developer.apple.com/documentation/appkit/nstableview/typeselectmatching(searchstring:) + +Recommended scope: first visible **non-row-number** column. Cap the result at ~120 characters to keep the search loop cheap. Skip when the user is editing (`tableView.editedRow >= 0`) so keystrokes don't both type-select and enter the field editor. + +--- + +## T6 - Replace blanket `reloadData()` with `reloadData(forRowIndexes:columnIndexes:)` + +Every call site below either targets a known subset of rows/columns or could. + +| Site | Current call | What it actually changes | Targeted alternative | +|---|---|---|---| +| `DataGridCoordinator.swift:209` (`releaseData()`) | `tableView.reloadData()` | wipes all rows because columns were just removed | OK - column structure changed; full reload is correct here | +| `DataGridCoordinator.swift:243` (`applyFullReplace()`) | `tableView.reloadData()` | column or row count changed | OK - `Delta.fullReplace`/`columnsReplaced` truly invalidates structure | +| `DataGridView+RowActions.swift:36` (`undoInsertRow(at:)`) | `tableView.reloadData()` | one row removed | `tableView.removeRows(at: IndexSet(integer: index), withAnimation: .slideUp)` - already what `applyRemovedRows` does for the normal delete path (line 232) | +| `DataGridView.swift:305` (`reloadAndSyncSelection`) | `tableView.reloadData()` when `needsFullReload` | structure changed | OK when row/column count truly changed; but `needsFullReload` is set whenever `oldRowCount != rowDisplayCount`, including +1/−1 deltas. Use `insertRows(at:withAnimation:)` / `removeRows(at:withAnimation:)` for ±k cases (already done by `applyDelta`). The full reload should fire only on `Delta.fullReplace` / column replacement. | +| `DataGridCoordinator.swift:418` (`invalidateCachesForUndoRedo`) | already targets `forRowIndexes:columnIndexes:` over visible rect | OK | +| `DataGridCoordinator.swift:166` (settings change handler) | already targets visible range × all columns | OK | +| `DataGridView+Sort.swift:251` (`setDisplayFormat`) | already targets visible range × all columns | OK | +| `DataGridCoordinator.swift:21` (`undoDeleteRow(at:)`) | `reloadData(forRowIndexes:columnIndexes:)` over single row × all columns | OK | + +Net change: only one site (`undoInsertRow`) is mis-using `reloadData()` for a single-row removal. Switch it to `removeRows(at:withAnimation:)` to match the rest of the delta path. + +Apple references: +- developer.apple.com/documentation/appkit/nstableview/reloaddata(forrowindexes:columnindexes:) +- developer.apple.com/documentation/appkit/nstableview/insertrows(at:withanimation:) +- developer.apple.com/documentation/appkit/nstableview/removerows(at:withanimation:) +- TableView Programming Guide → "Modifying the Contents of a Table View" → "Updating Data". + +--- + +## T7 - Confirm fixed-row-height path and skip `noteHeightOfRowsWithIndexesChanged:` + +TablePro sets `tableView.rowHeight = CGFloat(settings.rowHeight.rawValue)` (`DataGridView.swift:66`) and never sets `usesAutomaticRowHeights` (Apple property; default `false`). The Audit doc §6 ("Invariants to preserve") notes "`usesAutomaticRowHeights` must stay off for large datasets." Confirmed: TablePro is on the fixed-height path, which means `tableView(_:heightOfRow:)` is never queried and `noteHeightOfRowsWithIndexesChanged(_:)` is never needed. + +**Recommendation**: add a code-comment-free guarantee by setting `tableView.usesAutomaticRowHeights = false` explicitly at construction (`DataGridView.swift:51` in `makeNSView`). It's the AppKit default but Future-Us deserves the assertion. No call to `noteHeightOfRowsWithIndexesChanged(_:)` should ever be added. + +Apple references: +- developer.apple.com/documentation/appkit/nstableview/usesautomaticrowheights +- `Frameworks/AppKit/NSTableView.h` → `@property BOOL usesAutomaticRowHeights;` + +--- + +## T8 - Intercell spacing: align with Gridex + +Current TablePro: `tableView.intercellSpacing = NSSize(width: 1, height: 0)` (`DataGridView.swift:65`) plus `gridStyleMask = [.solidVerticalGridLineMask]`. + +Gridex: `intercellSpacing = NSSize(width: 0, height: 0)` plus `gridStyleMask = [.solidVerticalGridLineMask, .solidHorizontalGridLineMask]` (`gridex/macos/Presentation/Views/DataGrid/AppKitDataGrid.swift:32, 35`). + +The 1pt horizontal intercell space in TablePro is what currently draws the column separator (since horizontal grid is off); but with `.solidVerticalGridLineMask` enabled the grid line draws inside the cell rect anyway, so the 1pt extra space adds a faint background gap between the cell contents and the grid line. This isn't a bug, but for a denser, Excel-like grid match Gridex: zero intercell spacing, grid lines do the visual separation. + +**Recommendation**: change to `NSSize(width: 0, height: 0)` and decide whether to also enable `.solidHorizontalGridLineMask` based on the design system from finding-set 06 (HIG audit). Pure cleanup, no behavior change beyond visual density. + +Apple references: +- developer.apple.com/documentation/appkit/nstableview/intercellspacing +- developer.apple.com/documentation/appkit/nstableview/gridstylemask + +--- + +## T9 - Pasteboard: keep `pasteboardWriterForRow:` correct path; add parallel system types via `pasteboardItem(propertyList:forType:)`-equivalents + +Current TablePro: `DataGridView+RowActions.swift:178` `tableView(_:pasteboardWriterForRow:)` returns one `NSPasteboardItem` with three types: +- `com.TablePro.rowDrag` - the row index, internal use. +- `.string` - TSV row. +- `.html` - HTML table row. + +This is the correct modern path (preferred over the deprecated `tableView(_:writeRowsWith:to:)`). Two improvements for system interop: + +1. **Add `.tabularText`** alongside `.string`. Older Cocoa apps (Numbers, BBEdit) accept tab-separated data via `NSPasteboard.PasteboardType("NeXT tabular text pasteboard type")`. Cheap to add. +2. **Add a `public.json` (UTType.json) representation** for one-row drags. The DataGrid already has `JsonRowConverter` (used in `copyRowsAsJson` at line 152). Reusing it on the pasteboard item gives drag-out into JSON-aware tools for free. +3. **For multi-row drags**, return a `[NSPasteboardWriting]` (one item per row). `NSPasteboard` and `NSDraggingInfo` will iterate them; the receiver can read either as a list or as concatenated strings. AppKit's default behavior is correct here as long as each item carries the same type set. + +`NSPasteboardItem.setPropertyList(_:forType:)` is the analog the audit prompt asks about - TablePro doesn't use it because its current types are all strings, which is fine. If we ever attach a `[String: Any]` row dictionary (column → value) for receivers like Numbers, that's the API to use. + +Apple references: +- developer.apple.com/documentation/appkit/nstableviewdatasource/tableview(_:pasteboardwriterforrow:) +- developer.apple.com/documentation/appkit/nspasteboarditem/setpropertylist(_:fortype:) +- developer.apple.com/documentation/appkit/nspasteboard/pasteboardtype/string +- TableView Programming Guide → "Drag and Drop" + +Sequel-Ace precedent: drag uses calloc-cached column index mapping (`SPCopyTable.m:171–173`) and TSV/CSV output (`rowsAsTabStringWithHeaders:onlySelectedRows:blobHandling:` and `rowsAsCsvStringWithHeaders:...`). The system-interop angle is solved by emitting plain `.string` TSV; we already do that. + +--- + +## T10 - Drag/drop: validate signatures and `draggingDestinationFeedbackStyle` + +Current TablePro: +- `tableView(_:validateDrop:proposedRow:proposedDropOperation:)` at `DataGridView+RowActions.swift:196` - correct modern signature. +- `tableView(_:acceptDrop:row:dropOperation:)` at `DataGridView+RowActions.swift:212` - correct. +- `tableView.draggingDestinationFeedbackStyle = .gap` at `DataGridView.swift:101, 154` - correct for between-row inserts. +- Drag source: `pasteboardWriterForRow:` registered with `registerForDraggedTypes([com.TablePro.rowDrag])`. Internal-only payload - drop is rejected when `draggingSource as? NSTableView !== tableView` (line 203). + +Two minor issues: + +1. **`draggingDestinationFeedbackStyle` set only on the active-delegate path**: it's set inside an `if hasMoveRow` guard (line 102 / 154). When the table goes from "drop disabled" to "drop enabled" the style is reapplied - but when going the other direction we drop the registered types but never reset the feedback style. Cosmetic; harmless because no drop will be accepted. Set the style once in `makeNSView` and leave it. +2. **`validateDrop` returns `.move` even when `dropOperation == .above` is forced via `setDropRow`**: the current code (lines 205–207) calls `setDropRow(row, dropOperation: .above)` to coerce a row drop into a between-row drop, which is the documented escape hatch. Keep. + +Apple references: +- developer.apple.com/documentation/appkit/nstableview/draggingdestinationfeedbackstyle +- developer.apple.com/documentation/appkit/nstableviewdatasource/tableview(_:validatedrop:proposedrow:proposeddropoperation:) +- developer.apple.com/documentation/appkit/nstableviewdatasource/tableview(_:acceptdrop:row:dropoperation:) + +--- + +## T11 - Selection: prefer `selectionIndexesForProposedSelection` over per-row `shouldSelectRow` + +Current TablePro: I do not see `tableView(_:shouldSelectRow:)` implemented; selection is driven by binding push/pull (`DataGridView.reloadAndSyncSelection`) and `KeyHandlingTableView.mouseDown` setting `focusedRow`/`focusedColumn`. Good. + +**Recommendation**: when a row gets a `RowVisualState.isDeleted` flag, you may want to skip selection or mark it visually but allow undo. If selection ever needs row-level filtering, prefer: + +``` +func tableView( + _ tableView: NSTableView, + selectionIndexesForProposedSelection proposedSelectionIndexes: IndexSet +) -> IndexSet { + proposedSelectionIndexes // optionally filter +} +``` + +Apple's docs explicitly recommend this over `shouldSelectRow:` for multi-range selections: "Implementing this delegate method instead of `tableView(_:shouldSelectRow:)` is preferred for performance, especially when selection involves a range or multiple ranges of rows." + +Apple references: +- developer.apple.com/documentation/appkit/nstableviewdelegate/tableview(_:selectionindexesforproposedselection:) +- developer.apple.com/documentation/appkit/nstableviewdelegate/tableview(_:shouldselectrow:) + +--- + +## T12 - Context menu: split header menu and body menu via `NSTableView.menu` + per-row-view menus + +Current TablePro: +- Body context menu: `KeyHandlingTableView.menu(for:)` at `KeyHandlingTableView.swift:333` does the routing manually - falls through to `rowView.menu(for:)` for hit rows, otherwise `delegate?.dataGridEmptySpaceMenu()`, else `super`. This is a hand-rolled alternative to `NSTableView.menu`/`NSView.menu`. +- Header menu: `SortableHeaderView.menu` is set to an `NSMenu` whose `delegate = coordinator` (`DataGridView.swift:93–96`). The coordinator's `menuNeedsUpdate(_:)` (`DataGridView+Sort.swift:32`) populates per-column items dynamically. **This part is correct** - it uses `NSMenuDelegate.menuNeedsUpdate(_:)` exactly as Apple intends. + +The body side can be simplified: set `NSTableRowView.menu` from `tableView(_:rowViewForRow:)` (TablePro already returns `TableRowViewWithMenu` at `DataGridView+Columns.swift:113`), and set `tableView.menu` to the empty-space menu. AppKit auto-routes right-clicks: row hit → row view's menu; otherwise → table view's menu. The override of `menu(for:)` becomes unnecessary. + +``` +// in DataGridView.makeNSView: +tableView.menu = makeEmptySpaceMenu() // or wire via delegate +// in tableView(_:rowViewForRow:): +rowView.menu = makeRowMenu(for: row) // existing TableRowViewWithMenu pathway +``` + +Apple references: +- developer.apple.com/documentation/appkit/nsview/menu +- developer.apple.com/documentation/appkit/nstableview/menu (inherited) +- developer.apple.com/documentation/appkit/nsmenudelegate/menuneedsupdate(_:) + +Sequel-Ace precedent: `SPCopyTable.m` uses standard `menu(for:)` chain - no manual routing. + +--- + +## T13 - Column visibility: header NSMenu (already partly done) - but kill any custom popover + +Current TablePro: +- Header menu does include a "Hide Column" item (`DataGridView+Sort.swift:143–146`) and a "Show All Columns" item (lines 148–157). This is the right native pattern. +- There is also `TablePro/Views/Results/ColumnVisibilityPopover.swift` - listed in the directory, separate from the header menu pathway. + +**Recommendation**: +1. Keep the header `NSMenu` route. It is the standard macOS behavior (Finder list view, Mail, every other Apple table). +2. Audit `ColumnVisibilityPopover.swift` against §06 (HIG audit). If it is invoked from a toolbar button or a corner glyph, those entry points should funnel through the same header `NSMenu` items so behavior stays consistent. If the popover offers reordering or batch toggles that the menu lacks, retain only the unique features and bind them to `NSTableColumn.isHidden`. +3. The "Show All Columns" item should iterate `tableView.tableColumns` and set `column.isHidden = false`. AppKit's `autosaveTableColumns` (T1) persists the change. + +Apple references: +- developer.apple.com/documentation/appkit/nstablecolumn/ishidden +- developer.apple.com/documentation/appkit/nstableview/menu +- developer.apple.com/documentation/appkit/nstableheaderview + +(The "header has its own NSMenu attached at construction" idiom is exactly what `DataGridView.swift:93–96` already does - bind it to `NSTableView.headerView?.menu` instead of to the custom `SortableHeaderView` once T2 lands.) + +--- + +## T14 - Floating editor placement: place on tableView, not as cell subview (Gridex pattern) + +If for any reason a floating editor is retained after T3 (e.g. for a dedicated multi-line column type), match Gridex's placement: + +``` +// gridex/macos/Presentation/Views/DataGrid/AppKitDataGrid.swift:522–551 +let cellRect = tableView.frameOfCell(atColumn: col, row: row) +let container = EditContainerView() +container.frame = cellRect +tableView.addSubview(container) // direct child of tableView +tableView.window?.makeFirstResponder(editor) +``` + +Reasons: +- Tied to tableView's coordinate space → automatically clipped and translated when the table scrolls. +- First-responder changes do not destabilize the cell view hierarchy (Gridex audit comment at line 522 explicitly calls this out). +- No screen-coordinate math, no need to observe `boundsDidChangeNotification` to dismiss on scroll (Gridex's editor scrolls with the cell because it lives on the table). + +TablePro's current `CellOverlayEditor` does the opposite - places a borderless `NSPanel` in screen coordinates (`CellOverlayEditor.swift:48, 60–66`) and has to observe scroll to dismiss (lines 125–134). This is precisely the failure mode T3 fixes. + +Apple references: +- developer.apple.com/documentation/appkit/nstableview/frameofcell(atcolumn:row:) +- developer.apple.com/documentation/appkit/nsview/addsubview(_:) + +--- + +## T15 - `KeyHandlingTableView.mouseDown` re-implements `editColumn:row:with:select:` start-edit on second click - keep it, but document the contract + +`KeyHandlingTableView.swift:54–96`: the "click an already-focused cell once → start editing" UX. This is **not** an AppKit reimplementation - AppKit normally requires double-click. TablePro chose single-second-click for spreadsheet-style UX. Keep, but two adjustments: + +1. The branch at line 89–95 currently calls `editColumn(clickedColumn, row: clickedRow, with: nil, select: true)`. It should also pass through `tableView(_:shouldEdit:row:)` for the eligibility check - which `editColumn(_:row:with:select:)` already does internally (AppKit calls the delegate before opening the field editor). Confirmed. +2. The `clickCount == 2 && clickedRow == -1` branch (line 61) calls `dataGridAddRow()` - double-click on empty space adds a row. This is a TablePro convention, not native, but is harmless; document it in the delegate protocol. Native macOS does nothing on table empty-space double-click. + +Apple references: +- developer.apple.com/documentation/appkit/nstableview/editcolumn(_:row:with:select:) +- developer.apple.com/documentation/appkit/nstableviewdelegate/tableview(_:shouldedit:row:) + +--- + +## T16 - Drop hand-rolled cursor handling in `SortableHeaderView` + +`SortableHeaderView.resetCursorRects()` (lines 103–118), `viewDidMoveToWindow` (120), `layout` (125), `updateTrackingAreas` (130–143), `mouseMoved(with:)` (145–156), `isInResizeZone` (180–194). Once T2 deletes `SortableHeaderView`, these all go with it. AppKit's `NSTableHeaderView` already manages resize cursors on columns whose `resizingMask.contains(.userResizingMask)` is true - `DataGridColumnPool.configureColumn` already sets `resizingMask = .userResizingMask` (line 86), so the cursor is wired automatically when the stock header is restored. + +Apple references: +- developer.apple.com/documentation/appkit/nstableheaderview +- developer.apple.com/documentation/appkit/nstablecolumn/resizingmask + +--- + +## Net deletions and additions if T1–T7, T11, T12, T16 land + +Files to delete: +- `TablePro/Views/Results/SortableHeaderView.swift` (288 lines) +- `TablePro/Views/Results/SortableHeaderCell.swift` (182 lines) +- `TablePro/Views/Results/CellOverlayEditor.swift` (243 lines) +- `Core/Storage/FileColumnLayoutPersister.swift` (size unknown - referenced by `DataGridView.swift:377`) +- `Models/.../ColumnLayoutState.swift` (struct - kept as a transient if needed for legacy migration only) + +Code to delete inside surviving files: +- `TableViewCoordinator.savedColumnLayout`, `captureColumnLayout`, `persistColumnLayoutToStorage`, `currentSortState`, `onColumnLayoutDidChange` +- `DataGridView.syncSortDescriptors` (becomes a one-line `tableView.sortDescriptors = ...` push) +- `DataGridView.reloadAndSyncSelection` `needsFullReload` branch on row count delta - already redundant with `applyDelta` +- `KeyHandlingTableView.menu(for:)` (let AppKit route) +- `DataGridView+Editing.showOverlayEditor`, `commitOverlayEdit`, `handleOverlayTabNavigation`, `InlineEditEligibility.needsOverlayEditor` case +- `DataGridView+RowActions.undoInsertRow`'s `reloadData()` (use `removeRows(at:withAnimation:)`) + +Code to add: +- `tableView.autosaveName = ...` and `tableView.autosaveTableColumns = true` in `makeNSView` +- `tableView(_:sortDescriptorsDidChange:)` on the data source (~10 lines) +- `windowWillReturnFieldEditor(_:to:)` on `EditorWindow` plus `MultilineFieldEditor` shared instance (~30 lines) +- `tableView(_:typeSelectStringFor:row:)` on the delegate (~10 lines) +- `tableView.menu = ...` (one line; populate via existing menu builder) +- One-time UserDefaults migration translating legacy `ColumnLayoutState` JSON into AppKit's `NSTableView Columns ` dictionary (~40 lines, runs once and is gone) + +Net: roughly 700 lines removed, 100 lines added. Behavior identical or better (free incremental search, free multi-key sort with shift-click, free column-state restoration on relaunch). + +--- + +## Summary table + +| ID | Severity | TablePro file:line | Native API | Apple ref | +|---|---|---|---|---| +| T1 | HIGH | `DataGridCoordinator.swift:42, 56, 79`; `DataGridView.swift:218, 365`; `DataGridColumnPool.swift:27` | `NSTableView.autosaveName` + `autosaveTableColumns` | `nstableview/autosavename`, `nstableview/autosavetablecolumns` | +| T2 | HIGH | `SortableHeaderView.swift:207, 158`; `SortableHeaderCell.swift:32, 110`; `DataGridView.swift:264` | `sortDescriptorPrototype` + `tableView(_:sortDescriptorsDidChange:)` | `nstablecolumn/sortdescriptorprototype`, `nstableviewdatasource/tableview(_:sortdescriptorsdidchange:)` | +| T3 | HIGH | `CellOverlayEditor.swift:31, 148, 180`; `DataGridView+Editing.swift:78`; `KeyHandlingTableView.swift:209` | `editColumn:row:with:select:` + `windowWillReturnFieldEditor(_:to:)` returning multi-line `NSTextView` field editor | `nstableview/editcolumn(_:row:with:select:)`, `nswindowdelegate/windowwillreturnfieldeditor(_:to:)`, `nstextview/isfieldeditor` | +| T4 | MED | `DataGridCoordinator.swift:8` | Split into `DataGridDataSource`, `DataGridDelegate`, `DataGridFieldEditorController` | `nstableviewdatasource`, `nstableviewdelegate`, `nscontroltexteditingdelegate` | +| T5 | LOW | not implemented | `tableView(_:typeSelectStringFor:row:)` | `nstableviewdelegate/tableview(_:typeselectstringfor:row:)` | +| T6 | LOW | `DataGridView+RowActions.swift:36` (only mis-use) | `removeRows(at:withAnimation:)` | `nstableview/removerows(at:withanimation:)` | +| T7 | confirm | `DataGridView.swift:51` (set explicit `usesAutomaticRowHeights = false`) | `usesAutomaticRowHeights = false`, fixed `rowHeight` | `nstableview/usesautomaticrowheights` | +| T8 | LOW | `DataGridView.swift:65` | `intercellSpacing = .zero` (Gridex parity) | `nstableview/intercellspacing` | +| T9 | LOW | `DataGridView+RowActions.swift:178` (already correct) | Add `.tabularText` and `public.json` types | `nstableviewdatasource/tableview(_:pasteboardwriterforrow:)` | +| T10 | LOW | `DataGridView.swift:101, 154` | Set `draggingDestinationFeedbackStyle` once at construction | `nstableview/draggingdestinationfeedbackstyle` | +| T11 | LOW | not implemented | `tableView(_:selectionIndexesForProposedSelection:)` if/when needed | `nstableviewdelegate/tableview(_:selectionindexesforproposedselection:)` | +| T12 | MED | `KeyHandlingTableView.swift:333` | `tableView.menu` + `NSTableRowView.menu` | `nsview/menu`, `nsmenudelegate/menuneedsupdate(_:)` | +| T13 | LOW | `ColumnVisibilityPopover.swift` | Header `NSMenu` already does this; consolidate | `nstablecolumn/ishidden`, `nstableview/menu` | +| T14 | conditional | `CellOverlayEditor.swift:48, 60–66` | Place editor on `tableView` (Gridex `frameOfCell`) | `nstableview/frameofcell(atcolumn:row:)` | +| T15 | keep | `KeyHandlingTableView.swift:54–96` | Already routes through `editColumn(_:row:with:select:)` and `shouldEdit:row:` | `nstableviewdelegate/tableview(_:shouldedit:row:)` | +| T16 | LOW | `SortableHeaderView.swift:103–194` | Stock `NSTableHeaderView` cursor handling via `resizingMask` | `nstableheaderview` | + +Severity legend: HIGH = correctness/maintenance debt that compounds over time, MED = cleanup that pays back across multiple files, LOW = quick win or polish. diff --git a/docs/refactor/datagrid-native-rewrite/04-concurrency.md b/docs/refactor/datagrid-native-rewrite/04-concurrency.md new file mode 100644 index 000000000..93c64922d --- /dev/null +++ b/docs/refactor/datagrid-native-rewrite/04-concurrency.md @@ -0,0 +1,561 @@ +# DataGrid Native Rewrite, 04. Threading & Concurrency + +**Audit scope:** every off-main violation, redundant Task hop, missing debounce, leaked Combine subscription, and unstructured async on the read/render and selection paths of the TablePro DataGrid. Defines the structured-concurrency target architecture grounded in Apple's documented APIs. + +**Source basis:** +- TablePro: `Views/Results/DataGridCoordinator.swift`, `Views/Results/DataGridView.swift`, `Views/Results/ResultsJsonView.swift`, `Views/Results/TableRowsController.swift`, `Views/Results/CellOverlayEditor.swift`, `Views/Results/Extensions/DataGridView+Editing.swift`, `Views/Results/Extensions/DataGridView+Selection.swift`, `Core/Database/DatabaseManager.swift`, `Core/Plugins/PluginDriverAdapter.swift`, `Core/Database/ConnectionHealthMonitor.swift`, `Core/Events/AppEvents.swift` +- Gridex: `gridex/macos/Presentation/Views/DataGrid/AppKitDataGrid.swift` +- Sequel-Ace: `Source/Controllers/MainViewControllers/SPCustomQuery.{h,m}` (`SPQueryProgressUpdateDecoupling`) +- Audit context: `docs/refactor/datagrid-native-rewrite/../DATAGRID_PERFORMANCE_AUDIT.md` §2.4 + +**No code changes.** This file is the structured-concurrency target architecture for sprint planning. + +--- + +## 0. TL;DR + +The render path is `@MainActor` end-to-end, which is correct, but it is not *structured*: heavy work (`JSONTreeParser.parse`, `JsonRowConverter.generateJson`, `preWarmDisplayCache`) runs on main, debounces use `DispatchQueue.main.asyncAfter` instead of cancellable tasks, an event subscription drives `reloadData` with no coalescing, and several `Task { @MainActor in ... }` jumps fire from contexts that are already on the main actor. The blueprint replaces all of this with: + +- An `actor DataGridStore` that owns the row buffer, change manager, and prepared display cache. +- A `@MainActor final class DataGridCoordinator` that only renders, never formats. +- One `AsyncStream` per attached coordinator, debounced through `swift-async-algorithms` `.debounce(for: .milliseconds(100))`. +- Heavy work (`JSON parse`, `format pre-warm`, `DDL render`) on `Task.detached(priority: .userInitiated)` with explicit cancellation tokens. + +Citations: SE-0306 actors, SE-0314 `AsyncStream`, swift-async-algorithms `AsyncDebounceSequence`, Apple's "Updating an app to use Swift concurrency" (WWDC21 720), `NSDataAsset`, OSAllocatedUnfairLock. + +--- + +## 1. Findings, by severity + +Severity legend: **CRIT** (visible jank or correctness), **HIGH** (perf/leak), **MED** (correctness without user-visible impact today), **LOW** (style). + +### 1.1 Redundant Task hops while already on main + +**C1, HIGH, `Task { self?.releaseData() }` inside a main-thread Combine callback** +- **Where:** `Views/Results/DataGridCoordinator.swift:186-194` (`observeTeardown`). +- **Why it is wrong:** `AppEvents.shared.mainCoordinatorTeardown` is a `PassthroughSubject` declared on `@MainActor final class AppEvents`. The pipeline `.receive(on: RunLoop.main).sink { ... }` is already executing on the main thread when the closure fires, and `releaseData()` is `@MainActor`-annotated. Wrapping it in `Task { ... }` enqueues an additional unstructured task hop, which (a) defers teardown by at least one runloop turn, leaving the table view alive while subsequent work assumes it is gone, and (b) detaches the work from any caller cancellation chain. This is the pattern the team-lead flagged at the line referenced in the brief. +- **Apple-correct fix:** call `self?.releaseData()` directly. If you want to ensure the dispatch lands after the current draw cycle, schedule via `RunLoop.main.perform`, not `Task { ... }`. Because the closure already captures `[weak self]`, no `Task` wrapper is needed for nil-safety. +- **References:** WWDC21 720 "Swift concurrency: Update a sample app", "If you are already on the actor you want to run on, just call the function." SE-0316 §2. + +**C1b, HIGH, `Task { @MainActor in ... }` inside `controlTextDidEndEditing` selectors** +- **Where:** `Views/Results/Extensions/DataGridView+Editing.swift:202-205`, `Views/Results/Extensions/DataGridView+Editing.swift:224-227`. +- **Why it is wrong:** these closures run synchronously from `NSControl` text field delegate callbacks, which are already on the main thread (NSControl text editing always dispatches via the NSResponder chain on main). Wrapping `selectRowIndexes` and `editColumn` in a Task delays the move to the next column by one runloop and competes with the "did finish editing" cleanup, occasionally causing the cell editor to dismiss mid-creation. +- **Apple-correct fix:** schedule the next-column edit via `RunLoop.main.perform { ... }` so it lands on the next runloop turn *after* AppKit has finished tearing down the previous field editor. `Task` is not the tool for "step over to the next runloop turn", that is what `RunLoop.perform` and `OperationQueue.main.addOperation` exist for. + +**C1c, MED, `Task { @MainActor in ... }` inside `boundsDidChange` notification observers** +- **Where:** `Views/Results/CellOverlayEditor.swift:131-134`, `:142-145`. +- **Why it is wrong:** the observer is registered with `queue: .main`, so the closure already runs on main. The `Task { @MainActor in ... }` hop loses cancellability (the observer holds no reference to it) and creates a dependency on the runtime's task scheduler latency. If two notifications fire in the same runloop, two Tasks are enqueued, both calling `dismiss(commit:)`. +- **Apple-correct fix:** the observer block already runs on main; call `self?.dismiss(commit:)` directly. If overlay editor dismissal needs to defer until end-of-runloop, use `RunLoop.main.perform`. + +### 1.2 Heavy work on main + +**C2, CRIT, `JsonRowConverter.generateJson` + `JSONTreeParser.parse` on main inside `onChange(selectedRowIndices)`** +- **Where:** `Views/Results/ResultsJsonView.swift:154-169`, fired from `onChange(of: selectedRowIndices) { rebuildJson() }` at line 60. +- **Why it lags:** with a 50k-row result set and a 5k-row selection, `generateJson` builds a `[[String?]]`-to-JSON encoded string on main, then `JSONTreeParser.parse` re-decodes that string and walks it into an `JSONTreeNode` tree, both O(n) over the full payload. Because they are inside an `onChange` handler attached to the SwiftUI view body, they run synchronously on the main actor and freeze the UI for the duration of the parse. +- **Apple-correct fix:** Move both operations off main with `Task.detached(priority: .userInitiated)`. Push the result back to the view via `@State` set on the main actor: + +```swift +@State private var generationToken = UUID() + +private func rebuildJson() { + let token = UUID() + generationToken = token + let columns = tableRows.columns + let columnTypes = tableRows.columnTypes + let rows = displayRows + Task.detached(priority: .userInitiated) { + let converter = JsonRowConverter(columns: columns, columnTypes: columnTypes) + let json = converter.generateJson(rows: rows) + let pretty = json.prettyPrintedAsJson() ?? json + let parse = JSONTreeParser.parse(json) + await MainActor.run { + guard token == generationToken else { return } + cachedJson = json + prettyText = pretty + switch parse { + case .success(let node): parsedTree = node; parseError = nil + case .failure(let err): parsedTree = nil; parseError = err + } + } + } +} +``` + +The `generationToken` guard discards stale results when the user changes selection mid-parse. `Task.detached` is required because we need to run *off* `@MainActor`, a plain `Task { ... }` from a SwiftUI view body inherits the main actor. + +- **References:** SE-0304 (Structured concurrency); WWDC21 720 "Run heavy work off the main actor with `Task.detached`." + +**C2b, HIGH, `preWarmDisplayCache` runs synchronously inside `updateNSView`** +- **Where:** `Views/Results/DataGridView.swift:188-194`, calling `coordinator.preWarmDisplayCache(upTo:)` defined at `DataGridCoordinator.swift:305-327`. +- **Why it lags:** `preWarmDisplayCache` calls `CellDisplayFormatter.format` once per (row, column) cell across the visible viewport (≈ visible rows × column count). For 30 visible rows × 40 columns = 1,200 formatter calls on main, each potentially building a `DateFormatter` or escaping a JSON blob. This blocks the first frame after a result returns. +- **Apple-correct fix:** Off-load to `Task.detached`, push the formatted snapshot back to the coordinator via an actor-confined cache: + +```swift +actor DisplayFormatCache { + private var entries: [RowID: ContiguousArray] = [:] + func warm(rows: [Row], columnTypes: [ColumnType], formats: [ValueDisplayFormat?]) { ... } + func snapshot() -> [RowID: ContiguousArray] { entries } +} +``` + +When the snapshot is ready the coordinator (`@MainActor`) pulls it via `await store.displayFormats.snapshot()` and assigns into its own dictionary. Pre-warming during `updateNSView` should never block the paint cycle. + +### 1.3 Missing debounce + +**C5, CRIT, No debounce between ViewModel change and `reloadData` / `reconcileColumnPool`** +- **Where:** `Views/Results/DataGridView.swift:134-239`, every SwiftUI binding tick re-enters `updateNSView`, which recomputes `latestRows = tableRowsProvider()`, walks `reconcileColumnPool`, calls `coordinator.updateCache()`, possibly triggers `tableView.reloadData()`. Gridex solves this with `viewModel.objectWillChange.receive(on: RunLoop.main).debounce(for: .milliseconds(100), scheduler: RunLoop.main)` at `gridex/macos/Presentation/Views/DataGrid/AppKitDataGrid.swift:153-162`. +- **Why it lags:** rapid edits, sort changes, and `selectedRowIndices` changes each cause a SwiftUI invalidation, and SwiftUI fires `updateNSView` per invalidation. With no debounce, a multi-row delete (drag-select 200 rows, hit Delete) fires 200 `updateNSView` calls in quick succession, each rebuilding the visual state cache and reloading visible rows. +- **Apple-correct fix (preferred):** stop driving `updateNSView` for this. Mirror Gridex by attaching the coordinator to the ViewModel directly via Combine *or* an `AsyncStream`: + +```swift +@MainActor +final class DataGridCoordinator: NSObject { + private var changeStreamTask: Task? + + func bind(to store: DataGridStore) { + changeStreamTask?.cancel() + changeStreamTask = Task { @MainActor [weak self] in + for await change in store.changes.debounce(for: .milliseconds(100)) { + self?.apply(change) + } + } + } +} +``` + +`AsyncStream` is created inside `DataGridStore` actor (SE-0314). `swift-async-algorithms` provides `AsyncDebounceSequence` which suspends until 100 ms of silence elapses, then emits the latest value, exactly the Combine `.debounce` behavior, but cancellable and structured. + +- **Apple-correct fix (Combine, if SAA is not pulled in):** keep Combine, mirror Gridex's pattern: `viewModel.objectWillChange.receive(on: DispatchQueue.main).debounce(for: .milliseconds(100), scheduler: DispatchQueue.main).sink { ... }`. Two notes: (1) prefer `DispatchQueue.main` to `RunLoop.main` for `.debounce`, `RunLoop.main` only fires while the runloop is in `.default` mode, so it stalls during scrollwheel events (`NSEventTrackingRunLoopMode`); (2) `.debounce` fires *after* the silence window, so a single change still has a 100 ms delay, combine with `.throttle(for: .milliseconds(100), latest: true)` if first-change-fast + coalesced-after is desired (Sequel-Ace's `SPQueryProgressUpdateDecoupling` is a hand-rolled equivalent of `.throttle`, see §3). + +- **References:** swift-async-algorithms `AsyncDebounceSequence` (https://github.com/apple/swift-async-algorithms/blob/main/Sources/AsyncAlgorithms/AsyncDebounceSequence.swift), Combine `Publisher.debounce(for:scheduler:)`. WWDC22 110355 "Meet Swift Async Algorithms". + +### 1.4 `DispatchQueue.main.asyncAfter` for debounce/cooldown + +**C3, MED, `DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { copied = false }`** +- **Where:** `Views/Results/ResultsJsonView.swift:89-91`. +- **Why it is wrong:** the closure captures `copied` by reference and there is no cancellation token. If the user clicks Copy twice, two timers fire and the second resets `copied = false` 1.5 s after the second click, fine for this case, but the pattern leaks into others that *do* care about cancellation (e.g. `JSONSyntaxTextView.swift:215`, `HexEditorContentView.swift:128`). It also dispatches via `DispatchQueue` rather than the structured-concurrency runtime, so it is invisible to `Task.cancel()`. +- **Apple-correct fix:** replace with a cancellable `Task`: + +```swift +@State private var copyResetTask: Task? + +Button { + ClipboardService.shared.writeText(cachedJson) + copied = true + copyResetTask?.cancel() + copyResetTask = Task { @MainActor in + try? await Task.sleep(for: .milliseconds(1500)) + guard !Task.isCancelled else { return } + copied = false + } +} +``` + +Or use `swift-async-algorithms` `AsyncTimerSequence.timer(interval:)` if you want to broadcast the same tick to multiple awaits. + +- **References:** `Task.sleep(for:)` (SE-0329 Clock, Instant, Duration). `Task.isCancelled` after suspension is the canonical cancellation read-back. + +**C3b, MED, `DispatchQueue.main.asyncAfter` for "lazy reload" debounce in JSON syntax view** +- **Where:** `Views/Results/JSONSyntaxTextView.swift:215`, `Views/Results/HexEditorContentView.swift:128`. +- Same fix shape; consolidate into a single `Debouncer` actor reused across the codebase. + +### 1.5 Combine subscription lifecycle + +**C4, HIGH, `themeCancellable` not cancelled when the table view detaches from a window mid-session** +- **Where:** `Views/Results/DataGridCoordinator.swift:176-183` (`observeThemeChanges`), nominal cleanup at `Views/Results/DataGridView.swift:363-369` (`dismantleNSView`). +- **Why it leaks:** `dismantleNSView` is only called when SwiftUI tears down the `NSViewRepresentable`. If the SwiftUI parent stays alive but the underlying `NSTableView` is replaced (which happens on tab type toggles per `DataGridView.swift:113-115` comment), the coordinator persists and the theme/settings cancellables fire `Self.updateVisibleCellFonts(tableView: tableView)` on a stale or already-detached table view. The closure captures `[weak self]` but the subscription itself is not stored in a way that is invalidated when `releaseData()` runs, `releaseData()` at lines 196-215 nils out `delegate` but does not cancel `settingsCancellable` or `themeCancellable`. +- **Apple-correct fix:** cancel both in `releaseData()`. Better, replace the three Combine pipelines with a single `Task { for await event in AppEvents.shared.dataGridEvents { ... } }` and bind its lifetime to the coordinator via a `Task.cancel()` in `releaseData`: + +```swift +@MainActor +final class AppEvents { + let dataGridEvents: AsyncStream + private let dataGridContinuation: AsyncStream.Continuation + + init() { + var cont: AsyncStream.Continuation! + dataGridEvents = AsyncStream { cont = $0 } + dataGridContinuation = cont + } + + func emit(_ event: DataGridEvent) { dataGridContinuation.yield(event) } +} + +enum DataGridEvent: Sendable { + case settingsChanged + case themeChanged + case mainCoordinatorTeardown(connectionId: UUID) +} +``` + +Coordinator binds: + +```swift +private var eventTask: Task? + +func observeAppEvents() { + eventTask?.cancel() + eventTask = Task { @MainActor [weak self] in + for await event in AppEvents.shared.dataGridEvents { + guard let self else { return } + switch event { + case .settingsChanged: handleSettingsChanged() + case .themeChanged: Self.updateVisibleCellFonts(tableView: self.tableView) + case .mainCoordinatorTeardown(let id) where id == self.connectionId: self.releaseData() + default: break + } + } + } +} + +func releaseData() { + eventTask?.cancel() + eventTask = nil + ... +} +``` + +This collapses three Combine pipelines into one structured-concurrency loop with one cancellation token. `AsyncStream` is multi-producer-single-consumer; if multiple coordinators need the same stream, use an `AsyncBroadcastChannel` (swift-async-algorithms) or split the stream at `AppEvents` per consumer. + +- **Note:** if the team prefers to keep Combine, the minimum fix is: `releaseData()` must run `settingsCancellable = nil; themeCancellable = nil; teardownCancellable = nil` to break the retain on subscription closures. + +### 1.6 displayCache concurrency contract + +**C6, MED, `displayCache` mutation contract is implicit, not enforced** +- **Where:** `Views/Results/DataGridCoordinator.swift:17`, mutations at lines 199, 281, 286, 290, 296, 302, 325, 337, 344. +- **Current state:** `TableViewCoordinator` is `@MainActor`, so all coordinator methods are main-isolated. `PluginDriverAdapter` is a `final class` (no actor isolation, no `@MainActor`), but it is only invoked via `await` from `@MainActor` callers (`DatabaseManager`, `MainContentCoordinator`). The plugin's `executeUserQuery` is `async throws`, so the `await` hops to the plugin's nonisolated executor for the network call, then resumes on `MainActor` after the call returns. Result: `displayCache` is *currently* main-only. +- **Risk:** the `tableRowsProvider: @MainActor () -> TableRows` closure depends on the `@MainActor` annotation on the closure type, which is preserved through `DataGridView.tableRowsProvider`. If anyone refactors `tableRowsProvider` to drop `@MainActor` (e.g. to permit fetching from a background actor), the implicit safety vanishes silently. The compiler will not warn, it will accept the closure if no captured state requires main isolation, but the *callers* of `displayValue(forID:column:rawValue:columnType:)` from `tableView(_:viewFor:row:)` will then race. +- **Apple-correct fix:** make the contract explicit. Either (a) keep the cache on `@MainActor` and document it loudly: + +```swift +/// MUST be mutated only on @MainActor. The coordinator's @MainActor isolation +/// is the only enforcement; do not re-enter from `tableRowsProvider` callers. +private var displayCache: [RowID: ContiguousArray] = [:] +``` + +Or (b) move it into the `DataGridStore` actor: + +```swift +actor DataGridStore { + private var displayCache: [RowID: ContiguousArray] = [:] + func displayValue(forID id: RowID, column: Int) -> String? { ... } + func warm(visibleRange: Range, columnCount: Int) async { ... } +} +``` + +The (b) form is the path forward, it lets the pre-warm step run off main without the coordinator holding the data, and avoids the "silent contract" failure mode. The cell render path then queries `await store.displayValue(forID:, column:)`. `await` from `tableView(_:viewFor:row:)` is forbidden (NSTableView doesn't suspend), so the coordinator caches a synchronous main-actor copy of the prepared dictionary that the store updates atomically: + +```swift +@MainActor +final class DataGridCoordinator { + private var renderedDisplayCache: [RowID: ContiguousArray] = [:] + + func ingest(_ snapshot: [RowID: ContiguousArray]) { + renderedDisplayCache = snapshot + } +} +``` + +This is a "snapshot the actor's state to main on push" pattern, same as Gridex's `snapshotFromViewModel()` at `AppKitDataGrid.swift:173-186`. + +### 1.7 Hot-path closure caching + +**C7, LOW, `tableRowsProvider()` invoked repeatedly per operation** +- **Where:** `DataGridCoordinator.swift:220, 247, 259, 261, 305-307, 331, 397, 405, 450`. Each call materialises a `TableRows` value (a struct, but with `[Row]` and dictionary fields). Inside `applyDelta`, `applyInsertedRows`, `applyRemovedRows`, etc., the closure may be invoked 3-5 times per delta. +- **Why it matters:** `tableRowsProvider` is `@MainActor () -> TableRows`. If the underlying source is a `@Bindable` view model with `@Observable`, each call reads the latest published state. For deltas, that is fine. For *batches* (e.g. iterating over inserted indices to append to `sortedIDs`), the value should be cached once. +- **Apple-correct fix:** snapshot once at the top of each delta-handling method: + +```swift +func applyDelta(_ delta: Delta) { + let tableRows = tableRowsProvider() + switch delta { ... use `tableRows` everywhere ... } +} +``` + +For idempotent reads this is safe; for cases where you want to *observe* the post-mutation value, take a second snapshot after the mutator. This is pure micro-optimisation, but the Sequel-Ace IMP-cache pattern (`SPDataStorage.h:93-123`) demonstrates the value of caching the lookup once per inner loop. + +### 1.8 ConnectionHealthMonitor + +**HM1, sanity OK, 30s ping pattern is correctly off-main** +- **Where:** `Core/Database/ConnectionHealthMonitor.swift`. +- **State:** `actor ConnectionHealthMonitor`, `monitoringTask: Task?`, `Task.sleep(for: .seconds(Self.pingInterval))` inside a `while !Task.isCancelled` loop. `pingHandler` and `reconnectHandler` are `@Sendable () async -> Bool`. State transitions go through `await onStateChanged(...)` to `@MainActor` callers. Initial jitter `Double.random(in: 0...10)` correctly de-syncs multiple monitors. +- **One observation:** `lastPingTime: ContinuousClock.Instant?` is read/written from the actor's executor, race-free. Logging warns if the interval drifts under 5 s, which is the right canary. The actor is the canonical structured-concurrency pattern for this kind of long-running background work; **do not regress it.** +- **Citations:** SE-0306 actors; Apple's "Connection lifecycle" sample (WWDC21 10133). + +### 1.9 Sequel-Ace `IMP` caching → Swift equivalents + +Sequel-Ace's `SPDataStorage.h:93-123` caches Objective-C method `IMP` pointers (`cellDataAtRowAndColumn`, `rowAtIndex`, etc.) so the cell render loop can call them as bare C function pointers, bypassing `objc_msgSend` dispatch. In Swift, the equivalent levers are: + +- **`final class`** for protocol-witness elimination. Methods on `final class` are statically dispatched, no witness table lookup. The coordinator is already final (`DataGridCoordinator.swift:8`); good. `DataGridStore` (the proposed actor) should be `actor DataGridStore` (actors are implicitly final). +- **`@inlinable` + `@usableFromInline`** for cross-module inlining of hot accessors. Only useful if the type is exposed across module boundaries (e.g. into `TableProPluginKit`). Not needed for in-process render path. +- **`KeyPath`** lookups compile to a constant offset for stored properties; faster than `dynamicMember` reflection. `Row.values[col]` and `TableRows.rows[index]` are already direct array indexing, already optimal. +- **`ContiguousArray`** for cache locality. Per audit M8: `Row.values` is `[String?]` (`Array`), which on small sizes is fine, but the prepared display row should be `ContiguousArray`. Bridging: `Array(contiguousArray)` is O(1) when the source is unique. +- **`OSAllocatedUnfairLock`** instead of `os_unfair_lock_t` (Sequel-Ace's `qpLock` at `.m:3831`). Use this only if you need a non-actor synchronisation primitive in `Sendable` value types, e.g. inside a `final class`-but-not-actor cache that must be passed across isolation domains. Reference: WWDC22 110351 "Eliminate data races using Swift Concurrency". + +The render path should not need explicit locks: actor isolation + `@MainActor` is the model. Locks come back if (and only if) we need a `nonisolated let` synchronous accessor across isolation domains. + +--- + +## 2. Structured target architecture + +### 2.1 Type roles + +``` +┌─────────────────────────────────────────┐ +│ @MainActor final class │ +│ DataGridCoordinator (NSTableViewDelegate│ +│ / DataSource) │ +│ - renders cells from `renderedSnapshot` │ +│ - owns NSTableView reference │ +│ - emits user-input events to store │ +└──────────┬───────────────────┬──────────┘ + │ async push │ user events (Sendable) + │ (snapshot) ▼ + │ ┌───────────────────────┐ + │ │ actor DataGridStore │ + │ │ - row buffer (paged) │ + │ │ - displayCache │ + │ │ - sortedIDs │ + │ │ - pendingChanges │ + └──────────────┤ - changes: AsyncStream│ + └───────────────────────┘ + ▲ + │ async fetch + │ + ┌───────────────────────┐ + │ DatabaseManager │ + │ (@MainActor today) │ + │ + plugin executors │ + └───────────────────────┘ +``` + +`DataGridStore` is an `actor` (SE-0306). It owns mutable state. Its public surface is async. The coordinator drives input by sending events (`await store.applyEdit(rowID:column:value:)`) and drives output by reading `store.changes` as an `AsyncStream`. + +### 2.2 The change stream + +```swift +enum DataGridChange: Sendable { + case rowsInserted(IndexSet, snapshot: DisplaySnapshot) + case rowsRemoved(IndexSet, snapshot: DisplaySnapshot) + case cellsChanged([CellPosition], snapshot: DisplaySnapshot) + case fullReplace(snapshot: DisplaySnapshot) +} + +struct DisplaySnapshot: Sendable { + let columns: [String] + let columnTypes: [ColumnType] + let rowIDs: [RowID] + let cells: [RowID: ContiguousArray] + let visualState: [RowID: RowVisualState] +} +``` + +The store yields a snapshot per change. The snapshot is `Sendable` (the cells map carries only `String?` and value types). The coordinator consumes the snapshot on `@MainActor`, atomically replaces `renderedSnapshot`, and calls the matching `NSTableView.insertRows / removeRows / reloadData(forRowIndexes:columnIndexes:)`. + +### 2.3 Debounce point + +The coordinator binds with a single debounced loop: + +```swift +@MainActor +func bind(to store: DataGridStore) { + changeStreamTask?.cancel() + changeStreamTask = Task { @MainActor [weak self] in + let debounced = await store.changes.debounce(for: .milliseconds(100)) + for await change in debounced { + self?.apply(change) + } + } +} +``` + +`AsyncDebounceSequence` from swift-async-algorithms is the structured-concurrency equivalent of Gridex's `objectWillChange.debounce(for: .milliseconds(100))` at `AppKitDataGrid.swift:153-162`. 100 ms matches the empirical Gridex threshold; 80–120 ms is the acceptable range based on Apple's HIG guidance for "perceptual continuity" (under 100 ms = imperceptible to most users; 200 ms+ = noticeable lag). + +If `swift-async-algorithms` is not desired as a dependency, a hand-rolled debounce is short: + +```swift +extension AsyncSequence where Element: Sendable, Self: Sendable { + func debounced(for interval: Duration) -> AsyncStream { + AsyncStream { continuation in + let task = Task { + var pending: Element? + var timer: Task? + for try await value in self { + pending = value + timer?.cancel() + timer = Task { + try? await Task.sleep(for: interval) + guard !Task.isCancelled, let v = pending else { return } + continuation.yield(v) + pending = nil + } + } + timer?.cancel() + continuation.finish() + } + continuation.onTermination = { _ in task.cancel() } + } + } +} +``` + +(The library version is preferred, battle-tested, throttle/debounce/buffer all in one place.) + +### 2.4 Off-main heavy work + +Every formatter, parser, and serializer runs on `Task.detached(priority: .userInitiated)`: + +```swift +func warmDisplayCache(visibleRange: Range) { + warmTask?.cancel() + warmTask = Task.detached(priority: .userInitiated) { [store] in + await store.warm(visibleRange: visibleRange) + } +} +``` + +`Task.detached` is required to escape `@MainActor` inheritance. Inside the detached task, the work happens on the cooperative thread pool, then `await store.ingest(...)` posts the formatted snapshot back to the actor. The coordinator never sees the formatter; it only sees finished `String?` cells in `DisplaySnapshot`. + +JSON parse and JSON tree build (the C2 path) follow the same shape: + +```swift +func rebuildJsonTree(from snapshot: SelectionSnapshot) { + jsonTask?.cancel() + jsonTask = Task.detached(priority: .userInitiated) { + let json = JsonRowConverter.generate(snapshot) + let pretty = json.prettyPrintedAsJson() ?? json + let parsed = JSONTreeParser.parse(json) + await MainActor.run { + self.cachedJson = json + self.prettyText = pretty + self.applyParse(parsed) + } + } +} +``` + +Cancellation: every time the user changes selection, the previous task is cancelled before it can post stale results. + +### 2.5 Cancellation tokens + +All `DispatchQueue.main.asyncAfter` delays go through `Task.sleep` with cancellation: + +```swift +@MainActor +final class CooldownTimer { + private var task: Task? + func schedule(after: Duration, _ action: @escaping @MainActor () -> Void) { + task?.cancel() + task = Task { @MainActor in + try? await Task.sleep(for: after) + guard !Task.isCancelled else { return } + action() + } + } + func cancel() { task?.cancel(); task = nil } +} +``` + +ResultsJsonView's `copied = false` cooldown becomes one `CooldownTimer.schedule(after: .seconds(1.5)) { copied = false }`. The timer auto-cancels on view disappear via a `.task { ... }` lifecycle. + +### 2.6 Combine → AsyncStream migration for AppEvents + +`AppEvents` keeps its `PassthroughSubject` API for now (used in 20+ files), but adds an AsyncStream view for new consumers: + +```swift +@MainActor +final class AppEvents { + let dataGridSettingsChanged = PassthroughSubject() + var dataGridSettingsStream: AsyncStream { + AsyncStream { continuation in + let cancellable = dataGridSettingsChanged.sink { continuation.yield($0) } + continuation.onTermination = { _ in cancellable.cancel() } + } + } +} +``` + +The coordinator binds via `for await _ in AppEvents.shared.dataGridSettingsStream { ... }` inside its single `eventTask`. This avoids the leak in C4 (the stream's `onTermination` cancels the underlying Combine subscription when the consuming Task is cancelled in `releaseData`). + +For greenfield events (post-rewrite), use `AsyncStream` directly, drop the Combine subject. + +--- + +## 3. Reference patterns + +### 3.1 Gridex (`AppKitDataGrid.swift`) + +- **Bind on attach with debounce:** lines 146-163. `bind(to:)` sets `cancellables`, snapshots VM state, then subscribes to `objectWillChange.receive(on: RunLoop.main).debounce(for: .milliseconds(100), scheduler: RunLoop.main)`. This is the empirical "100 ms debounce works" anchor. +- **Visible-rect-only refresh:** lines 274-279. After a non-structural change, walk `tableView.rows(in: tableView.visibleRect)` and call `refreshRow(_:in:)` per row instead of `reloadData()`. Save: O(rows-on-screen) instead of O(rows-in-grid). +- **`MainActor.assumeIsolated` on nonisolated delegate methods:** lines 384-407 (`numberOfRows`, `viewFor`, `rowViewForRow`). NSTableView protocol conformance is declared `nonisolated` so the table view can call them without the runtime asserting actor isolation; `MainActor.assumeIsolated` is the structured-concurrency way to declare "this *is* on main, statically prove it" without paying the cost of a hop. This is the canonical pattern for AppKit delegate methods on `@MainActor` types and we should adopt it. +- **`releaseData()`:** lines 166-171. `cancellables.removeAll()` then nils the data. This is the leak-fix shape for C4. + +### 3.2 Sequel-Ace (`SPCustomQuery.m` and `SPQueryProgressUpdateDecoupling`) + +Sequel-Ace's progress-update decoupler at lines 3824-3870 implements a hand-rolled coalescing pattern: + +1. Background query thread calls `setQueryProgress:` on the decoupler. +2. The decoupler takes `qpLock` (`os_unfair_lock`), writes the new progress, sets `dirtyMarker` if not already set, releases the lock. +3. If `dirtyMarker` flipped from 0 to 1, it does `performSelectorOnMainThread:@selector(_updateProgress) ... waitUntilDone:NO`, this enqueues *one* main-thread call regardless of how many `setQueryProgress:` calls happened in between. +4. `_updateProgress` runs on main, drains the latest value, clears `dirtyMarker`, calls the user-supplied block. + +This is a single-flight throttle: the foreground thread sees the *latest* progress, never a queue of stale values. The Swift equivalent for our render path is `AsyncStream` with `.bufferingNewest(1)` on the continuation (SE-0314): + +```swift +let (stream, continuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) +``` + +That gives the same coalesce-to-latest behaviour without an explicit lock. The store's `changes` stream uses `bufferingPolicy: .bufferingNewest(16)`, one slot per change kind so that a `cellsChanged` does not get coalesced with a structural `rowsInserted`. The downstream `.debounce(for: .milliseconds(100))` then picks the last value within the window. + +Sequel-Ace also hot-caches `objc_msgSend` IMPs at `SPDataStorage.h:93-123` to dodge dispatch overhead. Swift equivalents, `final class`, `actor` (final by definition), `ContiguousArray`, `KeyPath`, were covered in §1.9. + +### 3.3 Apple sample reference + +- **WWDC21 720, "Update a sample app to Swift concurrency":** the canonical "remove unstructured `Task { ... }` hops when already on main" example. +- **WWDC22 110351, "Eliminate data races":** `OSAllocatedUnfairLock`, `Sendable` checking, actor isolation. +- **WWDC22 110355, "Meet Swift Async Algorithms":** debounce, throttle, merge, combineLatest. The `AsyncDebounceSequence` is exactly the tool we need for C5. +- **`NSDataAsset`:** for any blob preview that loads from the asset catalog. Not directly applicable to live query results, but if the plugin returns binary blobs via asset URLs, prefer `NSDataAsset(name:)` over `Data(contentsOf:)`, NSDataAsset memory-maps under the hood and decompresses lazily. + +--- + +## 4. Action checklist (sprint-ready) + +### Sprint 1, quick wins (1–2 days) +- [ ] **C1**: drop `Task { ... }` wrapper at `DataGridCoordinator.swift:190-193`. Call `releaseData()` directly. +- [ ] **C1b**: replace `Task { @MainActor in ... }` at `DataGridView+Editing.swift:202-205, 224-227` with `RunLoop.main.perform { ... }`. +- [ ] **C1c**: drop `Task { @MainActor in ... }` at `CellOverlayEditor.swift:131-134, 142-145`. Call `dismiss(commit:)` directly. +- [ ] **C3**: convert `DispatchQueue.main.asyncAfter` at `ResultsJsonView.swift:89-91` to a cancellable `Task` with `Task.sleep(for: .milliseconds(1500))` and cancel-on-tap. +- [ ] **C4 (minimum)**: nil out `settingsCancellable`, `themeCancellable`, `teardownCancellable` in `releaseData()` before nilling `delegate`. Verify no observers fire on detached table views. + +### Sprint 2, architecture +- [ ] **C2**: move `JsonRowConverter.generateJson` + `JSONTreeParser.parse` off main via `Task.detached(priority: .userInitiated)` with a generation token guard. +- [ ] **C2b**: move `preWarmDisplayCache` onto a future `actor DataGridStore` so warming runs detached and pushes a snapshot back to main. +- [ ] **C5**: add `.debounce(for: .milliseconds(100), scheduler: DispatchQueue.main)` between the change source and the coordinator's reload path. Prefer `swift-async-algorithms` `.debounce` if/when SAA is adopted. +- [ ] **C6**: declare the `displayCache` isolation contract explicitly. Migrate to `actor DataGridStore` ownership (push snapshots to a main-actor `renderedSnapshot`). +- [ ] **C7**: snapshot `tableRowsProvider()` once per delta in `applyDelta`, `applyInsertedRows`, `applyRemovedRows`, `displayValue(forID:column:rawValue:columnType:)`. + +### Sprint 3, wider concurrency cleanup +- [ ] **C3b**: same Task-based replacement at `JSONSyntaxTextView.swift:215`, `HexEditorContentView.swift:128`. Extract a shared `CooldownTimer` actor. +- [ ] **C4 (preferred)**: replace the three Combine pipelines with a single `AsyncStream` driven from `AppEvents`, consumed in one Task in the coordinator. +- [ ] Adopt `MainActor.assumeIsolated { ... }` on `nonisolated` NSTableView delegate methods (Gridex pattern at `AppKitDataGrid.swift:384-407`). +- [ ] Audit every other `Task { @MainActor in ... }` in `Views/Results/`, `Views/Filter/`, `Views/Connection/`, eliminate if already on main. + +### Invariants to preserve +- `ConnectionHealthMonitor` actor pattern (HM1), do not regress. +- `SQLSchemaProvider` in-flight-task pattern (CLAUDE.md), same shape applies to `DataGridStore` for "concurrent callers await the same fetch" if the store fetches lazily. +- `releaseData()` ordering: cancel observers before nilling `delegate` and detaching the table view (mirrors the `ConnectionStorage` "persist before notify" invariant, here "cancel before detach"). +- `tableRowsProvider: @MainActor () -> TableRows` annotation. If anyone strips `@MainActor` from this, displayCache races silently. + +--- + +## 5. Open questions for downstream tasks + +1. Will `swift-async-algorithms` be added as a SPM dependency? If yes, every debounce/throttle path collapses to one operator. If no, we maintain a small `Debouncer` actor and an `AsyncStream` extension. +2. Should `AppEvents` migrate fully to `AsyncStream`, or stay Combine and add streams as views? 20+ existing call sites today, full migration is a separate task. +3. `DataGridStore` actor: does it own the `DataChangeManager` too, or does the coordinator keep a `@MainActor` change manager and only push deltas to the store? Recommendation: change manager stays main (it drives undo/redo, which is main UI state); store owns the prepared display cache only. +4. Is `RunLoop.main.perform` acceptable for "next runloop turn" semantics, or do we want everything to be a Task? `RunLoop.perform` is the AppKit-native shape for "after the current event loop drains", Tasks are unbounded by event loop modes. For NSTableView edit-step-over (C1b), `RunLoop.perform` is correct; for app logic, Tasks are correct. +5. Plugin drivers are `Sendable` and `async`. If a future plugin spawns its own background task to push streaming rows, the store needs an `ingest(_ row: Row)` async path, confirm the plugin protocol supports that before extending the store. + +--- + +*End of concurrency analysis. Cross-references: §2.4 of the audit, §1.6 of the architecture-anti-patterns doc (TablePro coupling), and Gridex `AppKitDataGrid.swift:146-279` for the empirical reference implementation.* diff --git a/docs/refactor/datagrid-native-rewrite/05-swiftui-interop.md b/docs/refactor/datagrid-native-rewrite/05-swiftui-interop.md new file mode 100644 index 000000000..a65012e97 --- /dev/null +++ b/docs/refactor/datagrid-native-rewrite/05-swiftui-interop.md @@ -0,0 +1,322 @@ +# 05 - SwiftUI / AppKit Interop Audit + +Scope: every place SwiftUI hosts AppKit (or vice versa) inside the data grid surface and its surrounding chrome (sidebar, inspector, popovers, JSON viewer, filter panel). Goal: list every nested-hosting, double-source-of-truth, full-tree-rerender, and `NSViewRepresentable` misuse, then define a single boundary rule for the rewrite. + +Companion to: `01-architecture-anti-patterns.md`, `03-hig-native-macos-delta.md`. Subsumes section 2.5 of `DATAGRID_PERFORMANCE_AUDIT.md`. + +## 0. TL;DR + +TablePro mixes SwiftUI and AppKit at four different layers, and most pain comes from doing it more than once per surface: + +1. `DataGridView.updateNSView` (SwiftUI → AppKit) re-applies ~25 coordinator properties on every binding change because it has no last-applied snapshot. Apple's documented contract for `updateNSView` is "reconcile to current state" which is fine, but the implementation does no diff so every parent re-render walks the full property set and triggers `reloadData()` whenever the cached row count drifts. +2. The filter suggestion dropdown stacks three view systems on top of each other: `NSPopover` → `NSHostingController` → SwiftUI `ScrollView` driven by `@Published` properties on an `ObservableObject`. Every keystroke invalidates the whole `ForEach`. +3. The right-panel "inspector" is constructed twice. `MainSplitViewController` already uses `NSSplitViewItem(inspectorWithViewController:)` (the AppKit-correct inspector boundary), but the SwiftUI content inside it re-implements a header, segmented picker, and tab switching as a `VStack`, so we pay for two layers of chrome. +4. The Redis sidebar uses recursive SwiftUI `DisclosureGroup` for trees that can hold 50,000 keys. SwiftUI builds the entire tree eagerly per `displayNodes(searchText:)` call. +5. `searchText` is mirrored: the truth lives in `SharedSidebarState`, then is copy-pushed into `SidebarViewModel` via `onChange` and read back by the list. Two writers, two readers, eventual consistency by accident. +6. `EnumPopoverContentView` and `ForeignKeyPopoverContentView` re-implement a searchable list in SwiftUI for picker semantics that AppKit ships natively as `NSPopUpButton` and `NSComboBox`. +7. The general pattern for popover content is "wrap a SwiftUI view inside an `NSHostingController` inside an `NSPopover`". The hosting indirection is unavoidable when the content is SwiftUI, but the popovers contain content (lists, search, selection) that is more naturally AppKit and triggers an unnecessary boundary cross. + +## 1. Findings + +Severity: **CRIT** = blocks the rewrite or causes user-visible jank/correctness; **HIGH** = forces redundant rerenders or breaks single-source-of-truth; **MED** = duplicates platform behavior; **LOW** = isolated and acceptable but track. + +### S1 - `DataGridView.updateNSView` reconciles with no diff (HIGH) + +`Views/Results/DataGridView.swift:134-239`. + +What's wrong: + +- `updateNSView` re-assigns `coordinator.changeManager`, `isEditable`, `tableRowsProvider`, `tableRowsMutator`, `sortedIDs`, `displayFormats`, `delegate`, `dropdownColumns`, `typePickerColumns`, `customDropdownOptions`, `connectionId`, `databaseType`, `tableName`, `primaryKeyColumns`, `tabType` on every parent re-render. There is no equality check. +- It calls `coordinator.updateCache()` twice (`:185` and `:201`). +- It calls `coordinator.rebuildVisualStateCache()` unconditionally on every update (`:214`). +- It rebuilds the column metadata cache (`:186`) unconditionally. +- It always calls `delegate?.dataGridAttach(...)` (`:204`), which means the delegate sees a fresh attach on every binding change. +- "Structure changed" is detected by row/column count only (`:182`). Reordered columns or replaced rows of the same shape do not trigger a reload, but a benign `@Binding` ping does the full property dance. +- `reloadAndSyncSelection` (`:299`) has the inverse problem: it skips reload when row/column counts match, even if row IDs changed (this is what the comment at `:110-115` admits). + +Why this matters: `updateNSView` is on the hot path for every SwiftUI parent invalidation. For a grid that already runs near `NSCell` redraw budget, this adds work proportional to property count, not to what actually changed. It also makes it impossible to reason about what triggers a reload. + +Native-correct fix: + +- Store a `LastApplied` snapshot inside the `Coordinator` (which is already provided by `NSViewControllerRepresentable`/`NSViewRepresentable` via `makeCoordinator` and exists here as `TableViewCoordinator`). Compare to the new state and apply only changed slices. This is the canonical pattern from Apple's "Communicate with the view by using a coordinator" section in *Interfacing with UIKit / AppKit* (Apple Developer documentation, "NSViewRepresentable" reference). +- Move the property fan-out into a single `Coordinator.apply(_ snapshot: DataGridSnapshot)` call. The snapshot is one `Equatable` struct; if `snapshot == coordinator.lastApplied` the function returns early. +- Detect structural changes by `(rowIDsHash, columnIDsHash)` rather than counts. The data path already owns stable `RowID` values. +- `delegate?.dataGridAttach(...)` should only fire when `delegate` identity changes. +- Drop the duplicate `updateCache()` call. + +Reference: `NSViewRepresentable.updateNSView(_:context:)` documentation explicitly says "Update the configuration of your view to match the new state information provided in the `context` parameter." The supported pattern for expensive updates is to diff against the coordinator's last-applied state. + +### S2 - Filter suggestion dropdown rebuilds the whole `ForEach` per keystroke (HIGH) + +`Views/Filter/FilterValueTextField.swift:294-336` (the `SuggestionState` ObservableObject and `SuggestionDropdownView`). + +What's wrong: + +- `SuggestionState` is `ObservableObject` with `@Published var items` and `@Published var selectedIndex`. Both publishers fire on every keystroke (`updateSuggestions(for:)` writes both at `:184-185` and `:193-194`). +- `SuggestionDropdownView` observes the whole object via `@ObservedObject`. Any property change invalidates the body, which rebuilds the `ForEach(Array(state.items.enumerated()), id: \.offset)`. +- `id: \.offset` discards diffability. SwiftUI cannot reuse rows when the list shifts, so every keystroke is a full row reconstruction. +- The list is SwiftUI but lives inside an `NSHostingController` inside an `NSPopover` (see S3). One re-render here triggers a hosting-bridge layout pass. + +Native-correct fix: + +- Replace `ObservableObject` with `@Observable` (Swift Observation, `Observation` framework, macOS 14+, SE-0395). `@Observable` tracks property reads at the granularity of property access, so a body that reads only `state.items` does not invalidate when only `state.selectedIndex` changes (and vice versa). Selection redraws become row-local instead of list-wide. +- Switch `id: \.offset` to `id: \.self` (the items are `String`) so insertion/deletion is diffed properly. +- Better: replace this entire view with `NSTableView` content inside the popover (see the boundary rule in section 3). The dropdown is a list with single-selection, keyboard navigation, and a string per row - that is `NSTableView` shaped. + +Reference: SE-0395 *Observability* and Apple's "Migrating from the Observable Object protocol to the Observable macro" article. The `@Observable` macro participates in SwiftUI's per-property dependency tracking; `ObservableObject` invalidates per object. + +### S3 - Triple-nested hosting in popovers (MED) + +`Views/Components/PopoverPresenter.swift:11-34` and every caller (`FilterValueTextField:203`, `EnumPopoverContentView`, `ForeignKeyPopoverContentView`, etc.). + +What's wrong: + +- Every popover is `NSPopover` → `NSHostingController(rootView: SwiftUIView)` → SwiftUI content. When that SwiftUI content is itself an `NSViewRepresentable` (e.g., `NativeSearchField` is wrapped inside `EnumPopoverContentView`), we cross the boundary three times: AppKit (popover) → SwiftUI host → AppKit (`NSSearchField`). Each boundary owns its own first-responder, layout, and intrinsic-size machinery. +- `popover.behavior = .semitransient` is hard-coded in `PopoverPresenter`; callers cannot opt into `.transient` (auto-dismiss on outside click) without a separate API. This forces `FilterValueTextField` to install a manual `NSEvent.addLocalMonitorForEvents` to capture arrow keys and Return (`:218-251`), because `.semitransient` does not steal key events. +- The hosting controller's `intrinsicContentSize` is driven by SwiftUI layout, but `popover.contentSize` is set imperatively from caller-computed numbers (`:201`). Two sizing systems fight. + +Native-correct fix: + +Pick one boundary per popover: + +- **AppKit-content popover** (search/list/picker semantics): give `NSPopover` an `NSViewController` whose root view is `NSStackView { NSSearchField, NSScrollView { NSTableView } }`. No SwiftUI hosting, no `NSEvent.addLocalMonitorForEvents`, key handling is automatic via the responder chain. `NSPopover` already routes Escape via `cancelOperation(_:)`. +- **SwiftUI-content popover** (settings, simple forms): use SwiftUI's `.popover(isPresented:)` modifier directly. Do not route through `NSHostingController` manually. SwiftUI's popover modifier wraps an `NSPopover` for you with the correct first-responder semantics. + +Apple's "Mixing AppKit and SwiftUI" guidance (WWDC22 "Use SwiftUI with AppKit", Apple Developer documentation "Adding AppKit Views to a SwiftUI View Hierarchy"): pick the host once per surface. Nested `NSViewRepresentable` inside `NSHostingController` inside `NSPopover` is the explicit anti-pattern. + +The current `PopoverPresenter` should be deleted in favor of either pattern. Keep one helper *or* the other, not both. + +### S4 - Right panel re-implements inspector chrome inside a native inspector (MED) + +`Views/RightSidebar/UnifiedRightPanelView.swift:15-50`. +`Core/Services/Infrastructure/MainSplitViewController.swift:143-144` (the AppKit half, already correct). + +What's wrong: + +- `MainSplitViewController` already creates the inspector the AppKit-native way: + ```swift + inspectorHosting = NSHostingController(rootView: initialInspectorContent) + inspectorSplitItem = NSSplitViewItem(inspectorWithViewController: inspectorHosting) + ``` + This is correct: `NSSplitViewItem(inspectorWithViewController:)` (macOS 14+) gives us the system inspector behavior - collapse, drag-edge, sidebar inset, divider styling - without any SwiftUI involvement. +- But the SwiftUI content hosted inside that inspector (`UnifiedRightPanelView`) re-implements its own inspector chrome via `VStack { inspectorHeader; Divider(); content }` (`:41-50`). The header has its own segmented `Picker`, its own padding, its own divider - none of which matches the AppKit chrome the split-view item already provides. We pay for two layers of titles, two layers of dividers, and two coordinate systems for "is collapsed". +- `state.activeTab` (Details vs AI Chat) is application state but is rendered as if it were a tab control. AppKit's `NSTabViewController` is the native control for "two views, picker on top". + +Native-correct fix: + +- Drop the `inspectorHeader` from `UnifiedRightPanelView`. The split-view item already owns the inspector chrome. +- Render the tab picker as the toolbar's right-aligned segmented control (`NSToolbarItem` with `NSSegmentedControl`) - this is how Xcode and Mail do it. The active-tab state already exists; only the chrome moves. +- If we want to keep the picker inline with content, replace `UnifiedRightPanelView` with an `NSTabViewController` (macOS native) and host each tab's SwiftUI body individually in its own `NSHostingController`. Each tab gets a real responder boundary and the segmented control is the native chrome. +- Alternatively, since `NSSplitViewItem.behavior = .inspector` is already in use, the SwiftUI `.inspector` modifier (macOS 14+) is *not* the right pick here - we already have the AppKit version, and `.inspector` would compete. Stay with `NSSplitViewItem(inspectorWithViewController:)`. + +Reference: `NSSplitViewItem` Apple Developer documentation, macOS 14 "Inspector style" section. `NSTabViewController` is the canonical "segmented chooser + content area". + +### S5 - Redis key tree uses recursive SwiftUI `DisclosureGroup` for up to 50k nodes (MED → HIGH at scale) + +`Views/Sidebar/RedisKeyTreeView.swift:42-66`. + +What's wrong: + +- `renderNodes(_:)` returns `AnyView(ForEach(items) { ... })` and recursively calls itself for each namespace's children. SwiftUI eagerly walks the whole tree to build the view graph - there is no lazy expansion. A 50k-key namespace materializes 50k `DisclosureGroup` bodies at first render even when collapsed. +- `AnyView` erases identity, defeating SwiftUI's diffing. Combined with recursive `ForEach`, this is O(total nodes) work on every parent re-render. +- The tree state lives in two places: `expandedPrefixes: Set` (passed by `@Binding`, owned by `RedisKeyTreeViewModel.expandedPrefixes`) plus the implicit "is loaded" state inside the view. Keystrokes in the search field cause `displayNodes(searchText:)` to recompute the whole node array (`SidebarView:224`). +- `nodes.isEmpty` and `isLoading` are sibling parameters but `isTruncated` is rendered as a static string - none of this benefits from SwiftUI animation, list virtualization, or selection. + +Native-correct fix: + +- Use `NSOutlineView` with lazy expansion. The dataSource pattern: implement `outlineView(_:numberOfChildrenOfItem:)`, `outlineView(_:child:ofItem:)`, `outlineView(_:isItemExpandable:)`, `outlineView(_:objectValueFor:byItem:)`. NSOutlineView only asks for children of expanded items, so 50k keys nested under collapsed namespaces never get queried. +- Persist expansion via `NSOutlineView.autosaveExpandedItems = true` plus an `autosaveName`, or sync to `expandedPrefixes` via `outlineViewItemDidExpand/Collapse`. +- Selection: `outlineView(_:shouldSelectItem:)` and `outlineViewSelectionDidChange(_:)`. +- Search: filter `displayNodes` once on the model side; reload via `outlineView.reloadData()` when the filter changes. Or, for live filtering, `NSOutlineView` with a separate filtered tree - same data source, different root. +- Drop `AnyView` entirely; the outline view does its own diffing. + +Reference: Apple's "Outline View Programming Guide for Mac" (legacy doc, still authoritative). The 1k-node threshold is the project's existing rule (`CLAUDE.md` performance pitfalls); Redis hits it routinely. + +### S6 - `searchText` has two sources of truth (HIGH for correctness) + +`Views/Sidebar/SidebarView.swift:67, 95-97`. +`ViewModels/SidebarViewModel.swift:18` (`var searchText = ""`). +`Models/UI/SharedSidebarState.swift:20` (`var searchText: String = ""`). + +What's wrong: + +- The canonical store is `SharedSidebarState.searchText` - it is owned per-connection and survives across native window tabs. +- `SidebarView.init` copies it into a local `SidebarViewModel.searchText` (`:67`). +- `.onChange(of: sidebarState.searchText)` writes back into the view model (`:95-97`). There is no inverse direction; if anyone writes to `viewModel.searchText`, `sidebarState.searchText` is stale. +- `filteredTables`, `noMatchState`, and `RedisKeyTreeView`'s search input all read from `viewModel.searchText` (`:30-31, 133, 164, 224`). +- The audit (`DATAGRID_PERFORMANCE_AUDIT.md` 2.5 row S6) names `SchemaService.shared` as the second source. That field was renamed/moved at some point; the dual-source pattern survives between `SharedSidebarState` and `SidebarViewModel`. Same shape, same fix. + +Why this matters: any code that writes to `viewModel.searchText` directly (e.g., a future "Clear search" button on the view model) silently loses the value across tab switches. The "two stores, sync via onChange" pattern is exactly what `@Bindable` exists to eliminate. + +Native-correct fix: + +- Delete `SidebarViewModel.searchText`. Keep the value in `SharedSidebarState` only. +- Read directly from `sidebarState.searchText` in computed properties on the view (`filteredTables` becomes `tables.filter { $0.name.localizedCaseInsensitiveContains(sidebarState.searchText) }`). +- For places that need to mutate it (search field, "Clear" button), pass `Binding` to `sidebarState.searchText` via `@Bindable var sidebarState: SharedSidebarState`. `SharedSidebarState` is already `@Observable`, so `@Bindable` works directly. +- The view model becomes a pure command bus (batch operations, dialog state) with no synced state. + +Reference: SE-0395 Observation. With `@Observable` plus `@Bindable`, the canonical pattern is one model, many readers, no copies. + +### S7 - `NSHostingView(rootView:)` as JSON viewer window content (LOW, acceptable) + +`Views/Results/JSONViewerWindowController.swift:54`. + +What's there: `window.contentView = NSHostingView(rootView: contentView)`. + +This is fine. `NSHostingView` is the right choice for a one-off window whose content is fully SwiftUI and whose lifecycle matches the window's. The general guidance from Apple ("Adding AppKit Views to a SwiftUI View Hierarchy" and reverse) is: avoid `NSHostingView` *inside cells of an `NSTableView`* (creates a hosting controller per row), inside `NSCollectionViewItem`s, or anywhere it gets created and destroyed at high rate. A whole-window root view is none of those. + +Track only: if `JSONViewerView` itself starts using state that re-creates its `NSTextView` subview on every keystroke, we'd see frame thrash. As-is, the editor is bridged once and held alive by the window. + +Keep as-is. Document the rule (see section 3) so we don't expand this pattern into cells. + +### S8 - Enum/FK pickers re-implement `NSPopUpButton` / `NSComboBox` in SwiftUI (MED) + +`Views/Results/EnumPopoverContentView.swift:36-66`. +`Views/Results/ForeignKeyPopoverContentView.swift:42-91`. + +What's wrong: + +- Both views are SwiftUI `List` + `NativeSearchField` (which itself is `NSViewRepresentable` wrapping `NSSearchField`) inside an `NSPopover` (via `PopoverPresenter`). That is the triple-host stack from S3. +- `EnumPopoverContentView` is, semantically, a single-selection picker over a fixed list. That is `NSPopUpButton` (with `pullsDown = false`) or, if the list is long enough to need search, `NSComboBox` with `usesDataSource = true`. +- `ForeignKeyPopoverContentView` adds async loading and a search field. Native equivalent: an `NSPopover` containing an `NSViewController` with `NSSearchField` + `NSTableView` (single column, single selection). The "load-then-display" lifecycle is one `Task` on `viewWillAppear`, and selection commits via `tableViewSelectionDidChange(_:)`. +- The SwiftUI `.onKeyPress(.return)` handlers (`Enum:59-63`, `FK:77-82`) are necessary because SwiftUI `List` does not give us first-responder keyboard handling for free in a popover. AppKit `NSTableView` does. +- `currentValue == row.id` styling is a one-off `if/else` in SwiftUI; in AppKit it's the table view's selection state plus a row highlight style. + +Native-correct fix: + +- `EnumPopoverContentView` → drop the popover wrapper entirely. The cell editor for an enum column should attach an `NSPopUpButton` as the edit control (or, for inline editing, the cell's `NSTextField` opens an `NSPopUpButton` programmatically). For "fits in a popover" UX, an `NSPopover` containing one `NSTableView` is the right shape; no SwiftUI list, no search field unless the enum has more than ~50 entries. +- `ForeignKeyPopoverContentView` → `NSPopover` with an `NSViewController` whose view is `NSStackView` { `NSSearchField`, `NSScrollView { NSTableView }` }. Async loading goes in `viewWillAppear()`. Commit on `doubleAction` or Return. +- This also removes `NativeSearchField` from these two callers, since `NSSearchField` is now the actual subview. + +Reference: `NSPopUpButton`, `NSComboBox`, and "NSTableView Programming Guide" Apple Developer documentation. + +### S9 - Other `NSViewRepresentable` wrappers worth a pass + +Checked while auditing; all are appropriate single-boundary wraps of an AppKit primitive into SwiftUI. None nest hosting controllers: + +- `NativeSearchField` - wraps `NSSearchField`. Correct. +- `JSONSyntaxTextView` - wraps `NSTextView`. Correct. +- `HighlightedSQLTextView`, `ChatComposerTextView`, `StartupCommandsEditor`, `AIRulesEditor` - all wrap `NSTextView` for editor surfaces. Correct. +- `HexDumpDisplayView`, `HexInputTextView` - wrap `NSTextView` for hex content. Correct. +- `ShortcutRecorderView` - wraps custom `NSView` for key recording. Correct. +- `WindowAccessor`, `WindowChromeConfigurator`, `TerminalFocusHelper` - empty view side-effect helpers. Correct *use* of `NSViewRepresentable`, but `WindowAccessor` style helpers are a code smell; track but don't refactor here. +- `DoubleClickDetector` - wraps gesture recognizer to add double-click semantics over a SwiftUI row. This is reasonable today, but if we move the sidebar to `NSOutlineView` (S5) it disappears. +- `TristateCheckbox` - wraps `NSButton` with `.allowsMixedState`. Correct. +- `QuerySplitView` - uses `NSViewControllerRepresentable` (the right pick for a parent that owns child VCs). + +The only `NSViewRepresentable` that is *misused* is `DataGridView` itself - see S1. It is wrapping an `NSScrollView` whose document view is an `NSTableView` whose delegate is the `Coordinator`. That is conceptually three view controllers' worth of state living on a `Coordinator` object, which is why the `updateNSView` body is doing controller-level work. The right base type for that surface is `NSViewControllerRepresentable`, with a real `NSViewController` subclass (`DataGridViewController`) that owns the scroll view, table view, header view, drag types, autosave, and column pool. That viewController is also where `viewWillAppear` / `viewDidDisappear` give us natural hooks for `observeTeardown` and `dismantleNSView` work that today is scattered across `makeNSView` / `dismantleNSView` / `Coordinator`. + +## 2. State-system mismatches: `@State` vs `@Binding` vs `@Observable` + +Rules the rewrite should hold: + +- **`@State`** for value-type UI state local to one view that no other view needs to read. Examples in current code that are correct: `FilterPanelView.showSQLSheet`, `EnumPopoverContentView.searchText` (popover is short-lived). +- **`@Binding`** for two-way handoff of `@State` or model properties owned elsewhere. Use only when the parent already owns the canonical store. Today's misuse: `RedisKeyTreeView.expandedPrefixes` is `@Binding Set` constructed in `SidebarView:225` from `keyTreeVM.expandedPrefixes`. This works but the canonical pattern with `RedisKeyTreeViewModel` being `@Observable` is `@Bindable var keyTreeVM` and pass `$keyTreeVM.expandedPrefixes` directly - no manual `Binding(get:set:)` adapter. +- **`@Observable` (Swift Observation)** for reference-type state shared across views. Already adopted by `SharedSidebarState`, `SchemaService`, `SidebarViewModel`. Use `@Bindable` at the call site to derive bindings. +- **`ObservableObject` + `@Published`** is legacy. The only remaining offender after S2 is `FilterValueTextField.SuggestionState`. Migrate to `@Observable`. +- **`@StateObject`** does not appear in the audited files; if it shows up, it should be `@State` of an `@Observable` type. + +Where TablePro picks the wrong one today: + +- `SidebarViewModel.searchText` is `@Observable`-tracked but should not exist at all (S6). +- `FilterValueTextField.SuggestionState` is `ObservableObject` and should be `@Observable` (S2). +- `RedisKeyTreeView.expandedPrefixes` is `@Binding Set` constructed via manual `Binding(get:set:)` rather than `@Bindable var keyTreeVM` (S5 cleanup). +- `DataGridView` uses `@Binding selectedRowIndices` etc., which is correct for SwiftUI parents, but the *coordinator* should be `@Observable` so the SwiftUI side can derive bindings without round-tripping through `@Binding`. + +## 3. Target rule set + +Adopt these as the single boundary contract for the rewrite. They are derived from Apple's documented patterns and the findings above. + +### 3.1 The "AppKit-first surface" rule + +If a surface is fundamentally one of the following, use AppKit directly. Do not wrap in `NSViewRepresentable`. Do not host in a SwiftUI parent except at the topmost split-view level. + +| Surface | Native control | Why | +|---|---|---| +| Tabular data grid | `NSTableView` (in `NSScrollView`, in `NSViewController`) | Cell reuse, row redraw, drag, drop, autosave, accessibility for free | +| Hierarchical tree | `NSOutlineView` | Lazy expansion, autosave expansion, `NSTreeController` if needed | +| Window/workspace toolbar | `NSToolbar` + `NSToolbarItem` | Customization sheet, overflow menu, notarized look | +| Menu bar / context menu | `NSMenu` | Validation chain, key equivalents, services | +| Filter rules / predicate UI | `NSPredicateEditor` | Accessibility, localization of operators, undo | +| Single-choice picker (≤~50 fixed items) | `NSPopUpButton` | System styling, focus ring, `controlSize` | +| Searchable choice from a list | `NSComboBox` or `NSPopover { NSSearchField + NSTableView }` | Free first-responder, free arrow-key handling | +| Text editor (multi-line, syntax-aware) | `NSTextView` (inside `NSScrollView`) | Layout manager, find bar, ruler, accessibility | + +Boundary rule: each AppKit-first surface gets exactly one `NSViewControllerRepresentable` (preferred) or `NSViewRepresentable` wrapper at the SwiftUI/AppKit seam. Inside that wrapper, the view controller is pure AppKit. No nested `NSHostingController`. No nested `NSViewRepresentable`. + +### 3.2 The "SwiftUI-first surface" rule + +If a surface is one of the following, use SwiftUI directly. Do not drop into AppKit unless wrapping a primitive that SwiftUI does not ship. + +| Surface | SwiftUI | +|---|---| +| Settings panes | `Form { Section { ... } }` | +| Connection form | `Form` with `TextField`, `Picker`, `SecureField` | +| Modal dialogs (alert/confirmation) | `.alert`, `.confirmationDialog` | +| Inline popovers with simple state | `.popover(isPresented:)` | +| Onboarding / welcome flows | Plain SwiftUI views | + +Boundary rule: a SwiftUI-first surface that needs an `NSTextField`, `NSSearchField`, etc. wraps that one primitive once via `NSViewRepresentable`. No going back into SwiftUI from inside that wrap. + +### 3.3 The single-boundary invariant + +For any visible surface, there is exactly one SwiftUI ↔ AppKit transition between the window's content view and the leaf widgets the user touches. Counting boundaries: + +- `NSPopover` → `NSHostingController` → SwiftUI body containing an `NSViewRepresentable` of `NSSearchField` = **2 transitions**. Violates the invariant. (Today's S3.) +- `NSPopover` → `NSViewController` → `NSStackView` → `NSSearchField` + `NSTableView` = **0 transitions** (pure AppKit). Correct. +- `NSWindow` → `NSHostingView` → SwiftUI form = **1 transition**. Correct (today's `JSONViewerWindowController`). +- `NSSplitViewItem(viewController:)` → `NSHostingController` → SwiftUI body → `NSViewRepresentable` of `NSTableView` = **2 transitions**. Violates. The fix is to make the right pane an `NSViewController` directly (today's `DataGridView` lives one `NSHostingController` deeper than necessary). + +### 3.4 `NSViewRepresentable` vs `NSViewControllerRepresentable` + +- `NSViewRepresentable`: a single view with no lifecycle semantics. Use for `NSSearchField`, `NSTextField`, `NSButton`, custom one-off `NSView`s. +- `NSViewControllerRepresentable`: anything that owns subviews, has child view controllers, manages first-responder, or wants `viewWillAppear` / `viewDidDisappear` hooks. Use for the data grid, filter panel chrome (if it stays SwiftUI-hosted), Redis tree, FK/Enum popover content. + +The current `DataGridView` is `NSViewRepresentable` but does view-controller work in `makeNSView`/`updateNSView` and `dismantleNSView`. Migrate to `NSViewControllerRepresentable` with a real `DataGridViewController`. This is the structural change behind S1. + +### 3.5 The `@Observable` rule + +- New shared state types: `@Observable` reference type. Read via `let` or `@Bindable`. No `@Published`, no `ObservableObject`. +- Inside SwiftUI: `@State var model = MyObservable()` for owned, `@Bindable var model: MyObservable` for borrowed. +- Bindings to properties: `$model.property` (works because `@Bindable` synthesizes them). Never `Binding(get:set:)` over an `@Observable` property. + +### 3.6 The `updateNSView` rule + +Every `updateNSView` body must be of the form: + +```swift +func updateNSView(_ view: V, context: Context) { + let snapshot = Snapshot(...) + if context.coordinator.lastApplied == snapshot { return } + context.coordinator.apply(snapshot, to: view) + context.coordinator.lastApplied = snapshot +} +``` + +`Snapshot` is one `Equatable` struct that captures every input the AppKit view depends on. `apply` is the only place that touches AppKit. `lastApplied` lives on the coordinator (which is exactly what `makeCoordinator` is for, per Apple's "Coordinator" pattern in `NSViewRepresentable` / `NSViewControllerRepresentable` documentation). + +## 4. References (Apple) + +- "NSViewRepresentable" reference, Apple Developer documentation. Coordinator pattern, `update(_:context:)` contract. +- "NSViewControllerRepresentable" reference. Difference from `NSViewRepresentable`; when to choose which. +- "Adding AppKit Views to a SwiftUI View Hierarchy" article. +- "Adding SwiftUI Views to an AppKit App" article. +- WWDC22 "Use SwiftUI with AppKit" - explicit guidance against nested hosting. +- "Outline View Programming Guide for Mac" - `NSOutlineView` lazy data source pattern. +- "NSTableView Programming Guide" - view-based tables, cell reuse. +- `NSSplitViewItem` reference, macOS 14 inspector behavior. +- SE-0395 *Observability* and "Migrating from the Observable Object protocol to the Observable macro" article - `@Observable`, `@Bindable`, per-property dependency tracking. +- `NSPopover` reference - `behavior`, key event handling, sizing. +- `NSPopUpButton`, `NSComboBox` references - native picker primitives. + +## 5. What this implies for the rewrite (handoff to task #10) + +The interop work breaks into three independent vertical slices that can land in any order: + +1. **Data grid boundary** - replace `DataGridView: NSViewRepresentable` with `DataGridViewControllerRepresentable: NSViewControllerRepresentable`. Pull the property fan-out into a `Snapshot` + `Coordinator.apply` pair. Resolves S1 and unblocks the rendering and threading work in tasks #1 and #4. +2. **Sidebar tree boundary** - replace `RedisKeyTreeView` (and `FavoritesTabView`'s tree) with an `NSOutlineView`-backed `NSViewControllerRepresentable`. Drop `SidebarViewModel.searchText`, route through `SharedSidebarState`. Resolves S5 and S6. +3. **Popover boundary** - replace `PopoverPresenter` with two helpers: `AppKitPopover.show(controller:)` for AppKit-content popovers (Enum, FK, filter suggestion), and let SwiftUI-content popovers use `.popover(isPresented:)` directly. Migrate `EnumPopoverContentView`, `ForeignKeyPopoverContentView`, and `FilterValueTextField`'s suggestion dropdown. Resolves S2, S3, S8. + +The right-panel cleanup (S4) is a separate, smaller pass: drop `inspectorHeader` from `UnifiedRightPanelView`, move the segmented picker to the toolbar. No representable changes. + +`NSHostingView` in `JSONViewerWindowController` (S7) stays. Document the "one-off window" exception in the rewrite checklist so it doesn't regress. diff --git a/docs/refactor/datagrid-native-rewrite/06-hig-design-system.md b/docs/refactor/datagrid-native-rewrite/06-hig-design-system.md new file mode 100644 index 000000000..9a324dcd5 --- /dev/null +++ b/docs/refactor/datagrid-native-rewrite/06-hig-design-system.md @@ -0,0 +1,215 @@ +# 06 - HIG and Design-System Audit + +**Reviewer**: hig-specialist +**Scope**: Per-surface delta of TablePro UI vs Apple HIG and AppKit. Each finding names the specific native primitive and the HIG page that authorises the swap. Code locations are line-accurate against `main` at audit time. + +References used throughout: + +- HIG root: https://developer.apple.com/design/human-interface-guidelines/macos +- Tab views: https://developer.apple.com/design/human-interface-guidelines/tab-views +- Popovers: https://developer.apple.com/design/human-interface-guidelines/popovers +- Sheets: https://developer.apple.com/design/human-interface-guidelines/sheets +- Inspectors: https://developer.apple.com/design/human-interface-guidelines/inspectors +- Color: https://developer.apple.com/design/human-interface-guidelines/color +- Accessibility: https://developer.apple.com/design/human-interface-guidelines/accessibility +- Menus: https://developer.apple.com/design/human-interface-guidelines/menus +- Toolbars: https://developer.apple.com/design/human-interface-guidelines/toolbars +- Window anatomy: https://developer.apple.com/design/human-interface-guidelines/windows + +Per CLAUDE.md and the user's "full Apple-correct refactor" preference, this audit recommends complete replacements grounded in documented APIs. No phased patches. + +--- + +## Severity legend + +- **CRIT**: ships a non-Apple-feeling experience users notice (focus theft, look-and-feel, missing system gestures). Must fix in the rewrite. +- **HIGH**: visible HIG departure or accessibility hole. Should fix before next release. +- **MED**: subtle correctness gap (semantic colors, restoration, services). Fix during rewrite. +- **LOW**: polish. + +--- + +## Surface-by-surface findings + +### H1 [CRIT] Custom `ResultTabBar` reimplements native tab semantics + +- **Location**: `TablePro/Views/Results/ResultTabBar.swift:11-104`. Pure-SwiftUI HStack of buttons with hover state, manual close X, manual context menu (Pin / Close / Close Others). +- **HIG violation**: Tab views, https://developer.apple.com/design/human-interface-guidelines/tab-views. Tabs that switch among co-equal panes within the same window are exactly what `NSTabView` / `NSTabViewController` exists for. The current bar omits drag-reorder, drop, keyboard navigation (`NSTabViewController` gives `tabView(_:shouldSelect:)` + arrow-key cycling free), and standard accessibility role `AXTabGroup`. +- **Apple-correct primitive**: `NSTabViewController` configured with `tabStyle = .unspecified` (you draw the bar) or `.toolbar` (system draws it). Use `transitionOptions = []` for instant switching to match the current UX. Reference: https://developer.apple.com/documentation/appkit/nstabviewcontroller. +- **Why not window tabs here**: result sets belong to one query and one tab; they are not co-equal documents. Window tabs would imply users can drag a result set into a separate window, which makes no semantic sense for "results 1..N of one query". +- **Specifics**: + - Pin / unpin / close-others go on the same `NSMenu` AppKit builds for the tab control, with `NSMenuItem.keyEquivalent` for Cmd+W, Cmd+Option+W. + - `setAccessibilityRole(.tabGroup)` is implicit on `NSTabView`; the SwiftUI wrapper has zero a11y role, so VoiceOver currently announces "button" for each tab. + - Drag-reorder is satisfied via `NSTabView.tabViewItem(at:)` plus a `pasteboardWriter` strategy as already used in `DataGridView+RowActions.swift:178`. + +### H2 [CRIT] Custom `EditorTabBar` does not exist - top-level tabs use NSWindow tabs but app-level tab bar logic is reimplemented across SwiftUI + +- **Location**: window-level tabs are correctly using `NSWindow.tabbingMode = .preferred` at `TablePro/Core/Services/Infrastructure/TabWindowController.swift:60-64` and `MainContentView+Setup.swift:223-225`. There is no `EditorTabBar.swift` file (the audit reference at `02-overview` was speculative). However, the **payload-routing logic** that decides whether to replace the current tab vs open a new window-tab is hand-rolled in `MainContentCoordinator+Tabs` and the "active work" guard is bespoke (CLAUDE.md invariant "Tab replacement guard"). +- **HIG violation**: none for the tab bar itself (window tabs are the recommended pattern per Window anatomy → Tab bar). The departure is in the **per-tab content swap**: a single window already hosts multiple `QueryTab`s in `tabManager.tabs`, and these are swapped via SwiftUI re-render against a `selectedTabId`. That is `NSTabViewController` semantics implemented in SwiftUI. +- **Apple-correct primitive**: collapse the in-window `QueryTab` swap into native window tabs only. One `NSWindow` per `QueryTab`; share state through `MainContentCoordinator` (already keyed by `windowId`). The `tabManager.tabs` array becomes redundant because each window IS a tab. +- **User-memory caveat (`Native NSWindow tab perf cost accepted`)**: the user has explicitly accepted the Cmd+Number rapid-burst lag inherent to per-window tabs and rejected a custom-tab-bar refactor as not worth it. Honour that - keep `tabbingMode = .preferred`, do **not** propose a custom AppKit tab bar above an `NSTabView`. The recommendation here is to delete the SwiftUI-side `tabManager.tabs` parallel ledger and let `NSWindow` be the single source of truth for tab identity, which removes the "in two places must stay in sync" pain documented in CLAUDE.md (`updateWindowTitleAndFileState()` vs `ContentView.init` title chain). + +### H3 [CRIT] QuickSwitcher uses a SwiftUI sheet, takes app focus, blocks all other windows + +- **Location**: `TablePro/Views/QuickSwitcher/QuickSwitcherView.swift:14-249`, presented at `MainContentView.swift:100-218` via `.sheet(item: Bindable(coordinator).activeSheet)`. +- **HIG violation**: Sheets, https://developer.apple.com/design/human-interface-guidelines/sheets. A sheet is for "a task that's directly related to the window, ideally one with a definite end." Quick Switcher is the macOS Spotlight pattern: ephemeral search, dismiss on Esc / outside click / selection. It must not be a sheet (which is modal to its window and inherits the title bar). +- **Apple-correct primitive**: `NSPanel` with style mask `[.nonactivatingPanel, .titled, .fullSizeContentView]`, `becomesKeyOnlyIfNeeded = true`, `hidesOnDeactivate = true`, and `level = .floating`. Wrap content in `NSHostingController`. Reference: https://developer.apple.com/documentation/appkit/nspanel/init(contentrect:stylemask:backing:defer:) and the `.nonactivatingPanel` mask doc https://developer.apple.com/documentation/appkit/nswindow/stylemask/nonactivatingpanel. +- **Behaviour spec (Spotlight parity)**: + - Esc dismiss: AppKit free via `cancelOperation:` on `NSPanel`. + - Click-outside dismiss: `NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown])` + close. + - Centred on the active screen, not the window: `NSScreen.main.visibleFrame`. + - Cmd+P (Quick Switcher shortcut) toggles open/close: re-using the same panel instance. +- **Why a sheet is wrong here, concretely**: `MainContentView.swift:100` wraps the entire content tree in a `.sheet(item:)`. Opening Quick Switcher in window A, then trying to drag-paste from window B's data grid, fails because window B is no longer key (sheet steals key from A and prevents B from becoming key in some macOS versions). The on-key-press shortcut handler at lines 73-82 cannot fire when the user has switched apps and switched back. +- **Bonus benefits**: `NSPanel` participates in `NSApplication.windows` so `Window > Bring All to Front` works; sheet does not. + +### H4 [CRIT] FilterPanelView is a hand-rolled predicate editor + +- **Location**: `TablePro/Views/Filter/FilterPanelView.swift:8-248`, plus `FilterRowView.swift`, `FilterValueTextField.swift`. Logical mode picker (AND/OR), per-row column/operator/value, save-as-preset, preview SQL. +- **HIG violation**: macOS exposes `NSPredicateEditor` exactly for this: rule rows (AND/OR), template-driven operators per column type, drag-to-reorder, automatic compound expression, full VoiceOver support. The current view ships none of those for free. From the Color/Inspectors HIG: "Use system controls so people get familiar behaviors and your interface reflects current standards." +- **Apple-correct primitive**: `NSPredicateEditor` with `NSPredicateEditorRowTemplate` per column-type bucket. Reference: https://developer.apple.com/documentation/appkit/nspredicateeditor and https://developer.apple.com/documentation/appkit/nspredicateeditorrowtemplate. The `NSPredicate` returned drops directly into `SQLStatementGenerator` because the existing filter model already maps to SQL; you replace the UI, not the codegen. +- **Mapping plan**: + - One `NSPredicateEditorRowTemplate` per column data-type group: numeric (`<`, `<=`, `=`, `!=`, `>=`, `>`, `BETWEEN`), text (`contains`, `beginsWith`, `endsWith`, `matches`, `IS NULL`), date (relative + absolute pickers), boolean (toggle). + - Compound predicate at root for AND/OR (`NSCompoundPredicate.LogicalType`). + - Saved presets become `NSPredicate` archives via `NSKeyedArchiver` (already shipped with `requiresSecureCoding`); replaces the bespoke `FilterPreset` JSON. + - "Preview SQL" stays - it just reads the predicate via your existing visitor. +- **Accessibility unlock**: `NSPredicateEditor` rows expose row-add and row-remove buttons with correct labels, focus rings, and AXChildren for VoiceOver. The current `FilterRowView` lacks any `accessibilityLabel` for the per-row Add / Duplicate / Remove buttons. + +### H5 [HIGH] Enum and Foreign-Key pickers are custom `List + NativeSearchField` inside a SwiftUI popover + +- **Location**: `TablePro/Views/Results/EnumPopoverContentView.swift:12-99`, `TablePro/Views/Results/ForeignKeyPopoverContentView.swift:12-184`. +- **HIG violation**: Popovers, https://developer.apple.com/design/human-interface-guidelines/popovers. For a closed enum set (Postgres `ENUM`, MySQL `ENUM(…)`), the canonical control is `NSPopUpButton`: it owns its own popover-like menu, lazy-builds rows on click, and is the AXSyntheticControl `AXPopUpButton`. For a searchable open list (foreign-key reference), `NSComboBox` carries `usesDataSource = true` and a `completes` property, with a built-in expand affordance. +- **Apple-correct primitives**: + - **Enum cell editor**: `NSPopUpButton` configured as a pull-down with the enum members. Reference: https://developer.apple.com/documentation/appkit/nspopupbutton. The NULL marker becomes `NSMenuItem` with `representedObject = nil`. Replace `EnumPopoverContentView` entirely. + - **FK cell editor**: `NSComboBox` with `usesDataSource = true`. Reference: https://developer.apple.com/documentation/appkit/nscombobox. Set `numberOfVisibleItems = 12`, `completes = true`, `completionsForSubstring:` returns the matching FK rows. The "1000-row fetch" stays the same; you just hand it to the data source. Replace `ForeignKeyPopoverContentView` entirely. + - For very large referenced tables (>1000), keep the popover model but wrap an `NSTableView` inside it (with section headers and `NSTextFinder` for in-popover find). Reference popover sizing per HIG ("Make a popover the right size for its content; avoid scrolling"). +- **String violations also present here** - see H6. + +### H6 [CRIT] Hardcoded English strings in user-facing surfaces + +CLAUDE.md mandate: "Use `String(localized:)` for new user-facing strings in computed properties, AppKit code, alerts, and error descriptions." Verified the following keys already exist in `TablePro/Resources/Localizable.xcstrings` (so the only work is wrapping the call site): + +| File:line | Current | Replacement | Catalog key exists | +|---|---|---|---| +| `Views/QuickSwitcher/QuickSwitcherView.swift:34` | `Text("Quick Switcher")` | `Text(String(localized: "Quick Switcher"))` or `Text("Quick Switcher", comment: "...")` (SwiftUI auto-localises literals at view layer, but this is the sheet header that needs explicit comment) | yes (line 36408) | +| `Views/QuickSwitcher/QuickSwitcherView.swift:184` | `Text("Loading...")` | localised key exists | yes (line 27358) | +| `Views/QuickSwitcher/QuickSwitcherView.swift:198` | `Text("No objects found")` | localised key exists | yes (line 30764) | +| `Views/QuickSwitcher/QuickSwitcherView.swift:201` | `Text("No matching objects")` | localised key exists | yes (line 30629) | +| `Views/QuickSwitcher/QuickSwitcherView.swift:204` | `Text("No objects match \"\(viewModel.searchText)\"")` | **bug per CLAUDE.md** - this is a `String(localized:)` with interpolation antipattern even though the immediate site uses `Text`. Use `String(format: String(localized: "No objects match \"%@\""), viewModel.searchText)` | needs new key | +| `Views/QuickSwitcher/QuickSwitcherView.swift:235-241` | `case .table: return "TABLES"` etc. | All six need `String(localized:)`. These are section headers, not technical terms | needs keys: `TABLES`, `VIEWS`, `SYSTEM TABLES`, `DATABASES`, `SCHEMAS`, `RECENT QUERIES` | +| `Views/Filter/FilterPanelView.swift:60` | `Text("Filters")` | wrap explicitly given ambiguity with column-named "Filters" | already exists (21746) | +| `Views/Filter/FilterPanelView.swift:65-66` | `Text("AND")`, `Text("OR")` | logical operators, **technically borderline** - `AND` and `OR` are SQL keywords (per CLAUDE.md "Do NOT localize technical terms"). Recommend leaving as-is and adding a comment explaining. CLAUDE.md is explicit: technical terms (font names, database types, SQL keywords, encoding names) are not localised. | +| `Views/Filter/FilterPanelView.swift:107` | `Text("Enter a name for this filter preset")` | needs wrapping | catalog check needed | +| `Views/Results/ForeignKeyPopoverContentView.swift:55` | `Text("No values found")` | localised key exists | yes (line 31363) | +| `Views/Results/ForeignKeyPopoverContentView.swift:163` | `displayVal = "\(idVal) - \(second)"` | **CLAUDE.md violation: em dash forbidden anywhere**. Replace with `" - "` (hyphen-space-hyphen) or `": "`. Display string only, no key. | + +The QuickSwitcher header at line 34 sits inside a `Text(...)` literal, which SwiftUI does auto-localise - but the entry currently in the catalog has only English. The audit recommends explicit `String(localized:)` for headers in NSPanel mode (after H3) where `Text` literals will move into `NSAttributedString` paths that don't auto-localise. + +### H7 [MED] Theme engine bypasses semantic colors when a custom theme overrides them + +- **Location**: `TablePro/Theme/ResolvedThemeColors.swift:177-225`. Pattern is: `colors.windowBackground?.nsColor ?? .windowBackgroundColor`. The fallback is correct; the **override** is the issue. A user-defined theme that sets `windowBackground: "#FFFFFF"` produces a hardcoded sRGB white that ignores `Increase Contrast`, `Reduce Transparency`, and `accessibilityDisplayShouldReduceTransparency`. From the existing 03 audit (`docs/refactor/03-hig-native-macos-delta.md:65-79`), the asset catalog has zero color sets. +- **HIG violation**: Color, https://developer.apple.com/design/human-interface-guidelines/color. "Use system colors and dynamic colors so the app adapts to the current appearance and accessibility settings." Reduce Transparency, https://developer.apple.com/documentation/swiftui/environmentvalues/accessibilityreducetransparency. Increase Contrast, https://developer.apple.com/documentation/swiftui/environmentvalues/colorschemecontrast. +- **Apple-correct primitives**: + - Move all base colours into `Assets.xcassets` color sets with three or four variants: `Any Appearance`, `Dark`, `Any Appearance / High Contrast`, `Dark / High Contrast`. Reference: https://developer.apple.com/documentation/xcode/specifying-your-apps-color-scheme. + - Replace the optional-overlay model: a theme may **substitute** a SwiftUI `Color` from the catalog (`Color("StatusWarning")`) but must not ship a hex literal that bypasses the catalog. The hex path is fine for syntax-highlighter colours (`SyntaxColors`) where dark/light variants don't apply, but UI surfaces (`UIThemeColors.windowBackground`, `controlBackground`, `selectionBackground`) should resolve to named asset entries. + - Add `@Environment(\.accessibilityReduceTransparency)` and `\.colorSchemeContrast` reads in every site that uses `.ultraThinMaterial` or hex-derived colours, fall back to `.controlBackgroundColor` when set. +- **Theme accent colour**: `colors.accentColor` should be allowed only when the user explicitly opts into "override system accent". Default path must be `NSColor.controlAccentColor`, https://developer.apple.com/documentation/appkit/nscolor/2998125-controlaccentcolor, which respects the user's System Settings → Appearance → Accent picker. +- **The existing fallbacks at lines 178-208 are correct** - they reach for `.windowBackgroundColor`, `.controlBackgroundColor`, `.separatorColor`, `.labelColor`, `.selectedContentBackgroundColor`, `.unemphasizedSelectedContentBackgroundColor`, `.tertiaryLabelColor`, `.secondaryLabelColor`. Audit confirms these are the right semantic colors and the `?? `defaults are not the issue - only theme overrides bypass them. + +### H8 [MED] Window state restoration is opt-out + +- **Location**: `TablePro/Core/Services/Infrastructure/TabWindowController.swift:61` `window.isRestorable = false`. Same in `Views/Infrastructure/WindowChromeConfigurator.swift:43` (configurable, defaulted false in welcome / connection-form). No `encodeRestorableState(with:)` / `restoreState(with:)` overrides anywhere in the project (`grep` confirms 0 hits beyond `DataChangeManager.restoreState(from:)` which is unrelated). +- **HIG violation**: Window anatomy + system-document patterns. NSResponder ships `encodeRestorableState(with:)` / `restoreState(with:)` so apps survive Quit-and-Restart, Time Machine restore, and macOS state-restoration restarts (system-initiated restart for software updates). Reference: https://developer.apple.com/documentation/appkit/nsresponder/encoderestorablestate(with:) and https://developer.apple.com/documentation/appkit/nsresponder/restorestate(with:). HIG Windows says "When the user reopens your app, return to the previous state." +- **Apple-correct primitives**: + - Set `window.isRestorable = true`; supply a non-nil `restorationClass: AnyClass` that conforms to `NSWindowRestoration`. + - Override `encodeRestorableState(with coder:)` on `MainSplitViewController` (or a small `NSResponder` subclass): encode `connectionId`, `selectedTabId`, `tabbingIdentifier`, sidebar split position, scroll offset of the data grid (`tableView.enclosingScrollView?.contentView.bounds`), selected row indexes, applied filter NSPredicate, schema name. + - Implement `class func restoreWindow(withIdentifier:state:completionHandler:)` on the restoration class. The completion handler receives a fully-configured `NSWindow` and the state coder; you decode in the same order. + - The `TabPersistenceService` JSON layer can stay (it survives launches better than NSCoder state), but `isRestorable = true` lights up the OS-level "Reopen windows when logging back in" path that JSON-restore on `app launch` cannot replicate - that path runs *before* `applicationDidFinishLaunching` and is what users see when their machine restarts mid-session. +- **Quick win nearby**: `TablePro/Views/Settings/AppearanceSettingsView.swift` and the `Settings` scene at `TableProApp.swift:676` are also non-restorable. Settings panes that aren't restorable are fine; the main editor windows are not. + +### H9 [DONE] Cells already announce row X of Y to VoiceOver + +- **Location**: `TablePro/Views/Results/Cells/DataGridBaseCellView.swift:130-131` and `DataGridCellRegistry.swift:136`. Both `setAccessibilityRowIndexRange(NSRange(location:state.row, length: 1))` and `setAccessibilityColumnIndexRange(...)` are wired. +- **Status**: prior audit table H8 marked this as missing; the code was added since. **No action required.** Verify in the rewrite that cell-view subclasses (`NumericCell`, `BooleanCell`, etc.) inherit from `DataGridBaseCellView` so they pick up the calls, and that the row/column ranges update on row deletion/insertion (`tableRowsController.move` callsites). Reference: https://developer.apple.com/documentation/appkit/nsaccessibilityrow. + +### H10 [HIGH] Keyboard shortcuts split between SwiftUI `.keyboardShortcut` and `NSMenuItem` + +- **Location**: `TableProApp.swift:402-549` (SwiftUI `Button(...).keyboardShortcut(...)` declarations across `CommandGroup` blocks), `Views/QuickSwitcher/QuickSwitcherView.swift:69-82` (`.onKeyPress(.return)` and `.onKeyPress(characters:phases:)` used in lieu of menu items). +- **HIG violation**: Menus, https://developer.apple.com/design/human-interface-guidelines/menus. "Display the keyboard shortcut in a menu so people can discover it." Items hidden behind `.onKeyPress` modifiers are invisible to (a) the menu bar, (b) VoiceOver "VO+M" menu enumeration, (c) the system shortcut conflict report at System Settings → Keyboard → Keyboard Shortcuts → App Shortcuts. +- **Apple-correct primitives**: + - Quick Switcher's Ctrl+J / Ctrl+K / Ctrl+N / Ctrl+P movement should be `NSMenuItem.keyEquivalent` items inside a hidden menu installed when the panel is key window, OR use `NSEvent.addLocalMonitorForEvents(matching: .keyDown)` on the panel's content view. The latter is simpler since these shortcuts are only active when the panel is up. Reference: https://developer.apple.com/documentation/appkit/nsevent/addlocalmonitorforevents(matching:handler:). + - Return key in Quick Switcher: native `NSPanel` `cancelOperation:` / `insertNewline:` via responder chain rather than SwiftUI `.onKeyPress(.return)`. + - Where SwiftUI menu items are correct (most of `TableProApp.swift`), keep them - they DO reach `NSMenu`. The audit issue is exclusively the SwiftUI-internal `.onKeyPress` keypress handlers. +- **Conflict report sourced from prior audit** (`docs/refactor/03-hig-native-macos-delta.md:31-36`): five P0 shortcut conflicts (Cmd+D, Cmd+Y, Cmd+Option+Delete, Cmd+Ctrl+C, Cmd+L) are hard-coded in `KeyboardShortcutModels.swift`. Those are out of scope for this audit but flagged here because they live in the same shortcut machinery this finding asks you to centralise. + +### H11 [LOW] Custom pasteboard type without parallel public types + +- **Location**: `TablePro/Views/Results/DataGridView+RowActions.swift:178-181` (`com.TablePro.rowDrag`). `Core/Services/Infrastructure/ClipboardService.swift:21-32` already does the right thing for **copy**: `public.utf8-tab-separated-values-text` plus `public.utf8-plain-text` plus `com.TablePro.gridRows`. The drag-out path on the table view does NOT. +- **HIG violation**: Drag and drop (System experiences). Drag sources should write all the public types they can satisfy so that the destination chooses. Apple's `NSPasteboardWriting` doc: "An object that conforms to NSPasteboardWriting writes data to the pasteboard in one or more types." https://developer.apple.com/documentation/appkit/nspasteboardwriting. +- **Apple-correct primitive**: extend the pasteboard item produced at line 178-181 to also write `public.utf8-tab-separated-values-text` (the row rendered as TSV) and `public.utf8-plain-text` (concatenated cell values). Existing TSV serialiser in `ClipboardService.swift` already exists - call it. Result: drag a row out of the data grid into Numbers, TextEdit, Mail, or any text input and it pastes correctly. +- **Cross-link**: the prior audit `docs/refactor/03-hig-native-macos-delta.md:43` notes drag-out is also blocked at `validateDrop`. That P1 must be fixed first or this finding is unreachable. + +### H12 [LOW] Toolbar customisation works; window subtitle and represented URL are correctly used; one missing primitive + +- **Already correct**: + - `TabWindowController.swift:62` `window.toolbarStyle = .unified`. https://developer.apple.com/documentation/appkit/nswindow/toolbarstyle/unified. + - `MainWindowToolbar.swift:53` `allowsUserCustomization = true`. https://developer.apple.com/documentation/appkit/nstoolbar/allowsusercustomization. + - `MainContentView+Setup.swift:206-207, 218-220, 239-240` `window.representedURL = sourceFileURL`, `window.isDocumentEdited`, `window.subtitle`. https://developer.apple.com/documentation/appkit/nswindow/representedurl, https://developer.apple.com/documentation/appkit/nswindow/subtitle. +- **Missing**: + - Per-instance toolbar identifier `NSToolbar(identifier: "com.TablePro.main.toolbar.\(UUID().uuidString)")` at `MainWindowToolbar.swift:49`. UUIDs in toolbar identifiers defeat the system's per-toolbar customization autosave because the identifier changes on every launch. Reference: prior audit `docs/refactor/03-hig-native-macos-delta.md:47`. Replace with a stable `"com.TablePro.main.toolbar"` and route per-window state through `NSToolbar.autosavesConfiguration = true` + `NSToolbar.configuration` (macOS 13+). + - `NSToolbar.centeredItemIdentifiers` for the principal item (the connection name / database name combo) is not set - that means hover behaviour and overflow handling treats the principal item as a normal trailing item. Reference: https://developer.apple.com/documentation/appkit/nstoolbar/centereditemidentifiers. + +### H13 [MED] No App Intents / Spotlight integration + +- **Location**: codebase-wide. `grep` for `AppIntent`, `IndexableEntity`, `CSSearchableItem` returns zero hits in TablePro source. `NSUserActivity` is published in `TabWindowController.swift:198` for Handoff but is not eligible for Spotlight (no `isEligibleForSearch = true`, no `contentAttributeSet`). +- **HIG violation**: System experiences > Search/Shortcuts. App Intents make app actions discoverable from Spotlight, Shortcuts.app, Siri, and the Action Button. +- **Apple-correct primitives**: + - Add an `AppIntent` `OpenConnectionIntent` with a `@Parameter` for connection name, conforming to `OpenIntent`. Reference: https://developer.apple.com/documentation/appintents/openintent. + - Expose connections via `IndexedEntity` so Spotlight can suggest "Open " without TablePro running. Reference: https://developer.apple.com/documentation/appintents/indexedentity. + - Backfill `NSUserActivity.isEligibleForSearch = true` and `contentAttributeSet` (CSSearchableItemAttributeSet with title = `" - - "`) at `TabWindowController.swift:198-233` so already-opened tabs are also Spotlight-indexed. Reference: https://developer.apple.com/documentation/foundation/nsuseractivity/iseligibleforsearch. +- **Bonus**: an `AppShortcutsProvider` static list with `OpenConnectionIntent`, `RunFavoriteQueryIntent`, `OpenSampleDatabaseIntent` adds three Siri / Shortcuts entries with zero per-user setup. Reference: https://developer.apple.com/documentation/appintents/appshortcutsprovider. + +### H14 [LOW] No Services menu integration + +- **Location**: codebase-wide. `grep` for `NSServicesMenuRequestor`, `validRequestor(forSendType:returnType:)` returns zero hits. +- **HIG violation**: macOS Services menu, https://developer.apple.com/design/human-interface-guidelines/services. The Services menu lets the user pipe a selected SQL string to another app (e.g. "Open in Xcode", "New Note from Selection"). Implementing `NSServicesMenuRequestor` on `SQLEditorView` and on `DataGridView` lights up that menu for free. +- **Apple-correct primitive**: implement `validRequestor(forSendType:returnType:)` on the editor's `NSTextView` subclass and on `DataGridView`'s tableview. Reference: https://developer.apple.com/documentation/appkit/nsservicesmenurequestor. + +### H15 [LOW] Inspector right panel is custom, not `NSSplitViewController` inspector style + +- **Location**: `RightPanelState`, `MainContentView.toggleRightSidebar()`. The right panel is a SwiftUI view tree placed inside the split view. +- **HIG violation**: Inspectors, https://developer.apple.com/design/human-interface-guidelines/inspectors. macOS 14+ `NSSplitViewItem.behavior = .inspector` provides system-standard divider, collapse chevron in the toolbar (auto-installed when `NSToolbarItem.Identifier.toggleInspector` is in the toolbar - which TablePro already uses at `MainWindowToolbar.swift:93`). +- **Apple-correct primitive**: declare the right pane as `NSSplitViewItem(viewController:)` with `behavior = .inspector`. AppKit then handles all collapse / restore, animates correctly, sets `.canCollapseFromWindowResize`, and shows the right collapse chevron. Reference: https://developer.apple.com/documentation/appkit/nssplitviewitem/behavior/inspector. + +--- + +## Summary table + +| ID | Sev | Surface | File:line | Native primitive | HIG section | +|---|---|---|---|---|---| +| H1 | CRIT | Result tab bar | `Views/Results/ResultTabBar.swift:11-104` | `NSTabViewController` (`tabStyle = .toolbar`) | Tab views | +| H2 | CRIT | Per-window tab parallel ledger | `MainContentView.swift:53,77`, `Setup.swift:223-225` | Single source of truth: `NSWindow` tabs (already `.preferred`); delete SwiftUI `tabManager.tabs` | Window anatomy | +| H3 | CRIT | Quick Switcher sheet | `Views/QuickSwitcher/QuickSwitcherView.swift:14-249`, presented at `MainContentView.swift:201-210` | `NSPanel` with `[.nonactivatingPanel, .titled, .fullSizeContentView]` + `hidesOnDeactivate` | Sheets / Popovers | +| H4 | CRIT | Filter UI | `Views/Filter/FilterPanelView.swift`, `FilterRowView.swift` | `NSPredicateEditor` + `NSPredicateEditorRowTemplate` | (no specific HIG section; AppKit doc) | +| H5 | HIGH | Enum & FK pickers | `Views/Results/EnumPopoverContentView.swift`, `ForeignKeyPopoverContentView.swift` | `NSPopUpButton` (enum), `NSComboBox` (FK), `NSTableView`-in-popover for very large FK | Popovers | +| H6 | CRIT | Hardcoded English strings | listed in body | `String(localized:)` per CLAUDE.md | n/a (project rule) | +| H7 | MED | Theme color override path | `Theme/ResolvedThemeColors.swift:177-225`, `Settings/AppearanceSettingsView.swift:18-50` | Asset catalog color sets + `accessibilityReduceTransparency` reads | Color, Accessibility | +| H8 | MED | Window restoration off | `TabWindowController.swift:61`, `WindowChromeConfigurator.swift:43` | `NSWindowRestoration` + `encodeRestorableState/restoreState` | Window anatomy | +| H9 | DONE | Row/column index ranges | `Cells/DataGridBaseCellView.swift:130-131` | already correct | Accessibility | +| H10 | HIGH | `.onKeyPress` shortcuts not in menu | `QuickSwitcherView.swift:69-82` | `NSEvent.addLocalMonitorForEvents` on panel + standard responder chain | Menus | +| H11 | LOW | Drag pasteboard single type | `DataGridView+RowActions.swift:178-181` | Add `public.utf8-tab-separated-values-text` + `public.utf8-plain-text` | n/a (NSPasteboard doc) | +| H12 | LOW | Toolbar identifier UUID | `MainWindowToolbar.swift:49` | Stable identifier + `centeredItemIdentifiers` | Toolbars | +| H13 | MED | No App Intents / Spotlight | codebase | `OpenIntent`, `IndexedEntity`, `AppShortcutsProvider`, `NSUserActivity.isEligibleForSearch` | (App Intents framework) | +| H14 | LOW | No Services menu | codebase | `NSServicesMenuRequestor` | Services | +| H15 | LOW | Custom right inspector | `RightPanelState`, split view | `NSSplitViewItem.behavior = .inspector` | Inspectors | + +Crit/High count: 6 CRIT + 2 HIGH. These are the items the unified blueprint (task #10) must address. + +--- + +## Notes on overlaps and de-scoping + +- The 2026-05-03 prior audit (`docs/refactor/03-hig-native-macos-delta.md`) already catalogued H3 (QuickSwitcher sheet), H8 (no NSWindowRestoration), H10 (shortcut conflicts in `KeyboardShortcutModels.swift`), and the asset-catalog gap referenced in H7. Findings here are intentionally compatible: the citations cross-reference the prior doc rather than re-deriving them. The rewrite blueprint (task #10) should consume both documents. +- Per-cell accessibility (H9) was a known gap when the prior audit and the section-2.6 table were written; it has since been fixed in `DataGridBaseCellView.swift`. Marked DONE here so the rewrite does not re-implement what already works. +- Per user memory `Native NSWindow tab perf cost accepted`, H1/H2 do **not** propose a custom AppKit tab bar above an `NSTabView`. H1 is constrained to result-set tabs (within one window); H2 explicitly preserves `tabbingMode = .preferred` and only proposes deleting the SwiftUI parallel ledger. +- "Filters" key vs SQL-keyword "AND/OR": kept English under CLAUDE.md technical-term carve-out. The audit flags it for explicit comment in source rather than localisation. +- Em-dash hit at `ForeignKeyPopoverContentView.swift:163` is a CLAUDE.md violation for source files; included in H6 because it ships into the FK display string at runtime. diff --git a/docs/refactor/datagrid-native-rewrite/07-memory.md b/docs/refactor/datagrid-native-rewrite/07-memory.md new file mode 100644 index 000000000..364cc1893 --- /dev/null +++ b/docs/refactor/datagrid-native-rewrite/07-memory.md @@ -0,0 +1,510 @@ +# DataGrid Native Rewrite - 07 Memory Model and Data Structures + +**Scope:** retention bloat, hot-path singleton lookups, suboptimal containers, cache and eviction strategy, ref-count traffic in inner loops, plugin boundary copy cost, result-set RAM ceiling. +**Method:** static read of files listed in the prompt plus the call sites that drive the hot paths (`tableView(_:viewFor:row:)`, `applyContent`, `applyVisualState`, `updateWindowTitleAndFileState`). +**Cross-references:** audit sections 2.7 (memory) and 7 (open questions). Streaming storage at the plugin boundary overlaps with the data-path agent (§2.2 D5) and is recapped here for the memory dimension only. + +--- + +## 0. Executive summary + +The grid's memory model has four classes of issue: + +1. **Unbounded growth.** `displayCache: [RowID: [String?]]` and `rowVisualStateCache: [Int: RowVisualState]` have no ceiling, no eviction, no cost accounting. A 1M-row paginated session that revisits pages keeps every formatted page resident until `releaseData()`. +2. **Hot-path singleton + bridging cost.** Every cell render reaches `ThemeEngine.shared.dataGridFonts` 3 to 4 times and `ThemeEngine.shared.colors.dataGrid` once; every keystroke in a file-backed query tab bridges the full query string and the saved snapshot to `NSString` to compute `isFileDirty`. These are not amortized. +3. **Reference-counted boxing in inner loops.** `[String?]` for row values is `Array>` - each non-nil cell is a heap `_StringStorage` with retain/release on copy. `displayCache` rebuilds these arrays per row, doubling the retain traffic. +4. **Undo retains full row snapshots.** `recordRowDeletion(originalRow: [String?])` retains the entire row; batch deletes retain the full set of rows. There is no `levelsOfUndo` cap. A 1k-column wide-row delete keeps ~4k objects alive per entry. + +Below: severity, file:line, current footprint, target state with the Apple API. + +--- + +## 1. `displayCache` is unbounded - `NSCache` with cost accounting + +**Severity:** CRIT (M1 in audit). +**Location:** `TablePro/Views/Results/DataGridCoordinator.swift:17, 266–283, 305–327`. + +**Current footprint.** + +```swift +private var displayCache: [RowID: [String?]] = [:] +``` + +- Keyed by `RowID` enum (`.existing(Int)` or `.inserted(UUID)`) - `Hashable` value type, fine. +- Per-entry cost: a `[String?]` of `cachedColumnCount`. For a 50-column row at average value width 32 chars, each row is roughly `50 × (8 byte tag + 16 byte String header) + 50 × ~48 byte storage = ~3.4 KB`. At 100k visited rows: **~340 MB** held in `displayCache` alone with no eviction signal. +- `releaseData()` (line 198) is the only purge path; pagination, sort, filter, or scroll never drop entries. +- `pruneDisplayCacheToAliveIDs()` (line 329) only runs on row removals, not on memory pressure. + +**Why a Swift dictionary is wrong here.** `Dictionary` does not respond to memory pressure. `NSCache` does - it auto-evicts on memory warnings (Foundation, available since OS X 10.6) and supports both `countLimit` and `totalCostLimit`. It is also thread-safe by contract (we still own this on `@MainActor`, but the contract avoids accidental crashes). + +**Target state.** + +```swift +private let displayCache: NSCache = { + let cache = NSCache() + cache.countLimit = 5_000 + cache.totalCostLimit = 32 * 1024 * 1024 + cache.evictsObjectsWithDiscardedContent = true + return cache +}() +``` + +`NSCache` requires `AnyObject` keys and values, so: + +- Wrap `RowID` in a small `final class RowIDKey: NSObject` with `isEqual` and `hash` derived from the underlying enum (`NSObject` keys are how `NSCache` is intended to be used; the documentation calls this out: "Unlike an `NSMutableDictionary` object, a cache does not copy the key objects"). +- Wrap the per-row `[String?]` in `NSArray` (bridged from `[NSString?]`) or in a tiny `final class RowDisplayBox` holding `ContiguousArray`. The box wins because `NSArray` of optionals forces sentinels. +- Pass `cost:` proportional to `(columnCount * averageStringByteLen)` so `totalCostLimit` enforces a real RAM ceiling. + +**Apple API references.** + +- `NSCache` - countLimit, totalCostLimit, evictsObjectsWithDiscardedContent, automatic eviction under memory pressure. +- `NSDiscardableContent` - implement on the row box to opt into discardable eviction; pairs with `evictsObjectsWithDiscardedContent`. + +**Sizing note.** macOS does not page-out aggressively; once the working set is wired the system swaps. A hard `totalCostLimit` is the only durable defense. + +--- + +## 2. `isFileDirty` bridges full query string per keystroke + +**Severity:** HIGH (M2 in audit). +**Location:** `TablePro/Models/Query/QueryTabState.swift:266–272`. +**Callers:** `MainContentView+Setup.swift:181, 207, 240`, `MainContentView+Bindings.swift:129`, `MainEditorContentView.swift:397`, `MainContentView.swift:230`, `MainContentCommandActions.swift:303, 422, 599`. The window title pipeline calls this on every editor change. + +**Current footprint.** + +```swift +var isFileDirty: Bool { + guard sourceFileURL != nil, let saved = savedFileContent else { return false } + let queryNS = query as NSString + let savedNS = saved as NSString + if queryNS.length != savedNS.length { return true } + return queryNS != savedNS +} +``` + +- `query as NSString` and `saved as NSString` perform a String → NSString bridge each call. For a Swift `String` backed by a native `_StringStorage`, this is an `O(1)` allocation of a bridged `_StringStorage` proxy if the string is already UTF-8 - but the proxy is still a heap object that immediately gets refcounted twice (assignment + comparison) and released on scope exit. +- For very large dumps (the audit warns SQL dumps can be millions of chars on a single line), `queryNS != savedNS` falls through to a full character compare when lengths match. +- This runs **per keystroke** because `updateWindowTitleAndFileState()` and the SwiftUI binding chain re-evaluate it on every `query` mutation. + +**Target state.** Cache a `(byteLength: Int, fnv64: UInt64)` snapshot of `savedFileContent` at the moment of save, then on each call: + +```swift +struct SavedSnapshot: Equatable { + let byteCount: Int + let hash: UInt64 +} + +private(set) var savedSnapshot: SavedSnapshot? + +var isFileDirty: Bool { + guard sourceFileURL != nil, let saved = savedSnapshot else { return false } + if query.utf8.count != saved.byteCount { return true } + return query.fnv1a64() != saved.hash +} +``` + +`String.utf8.count` is O(1) on native Swift strings (the count is stored on the storage class). No `NSString` bridge, no second copy. Hash computation runs once per save, not once per keystroke. + +**Apple API references.** + +- `String.utf8.count` - documented O(1) on native UTF-8 storage (Swift evolution SE-0247). +- `Hasher` from Swift stdlib for the hash if cryptographic quality is not needed; otherwise `CryptoKit.SHA256` once at save time. +- For the original NSString approach, see the `String/NSString` bridging doc - bridging is "near-free" for ASCII but allocates for non-ASCII contiguous strings every call. + +**Why not just compare `query == saved`?** Same complexity in the worst case (full compare), and Swift's `String == String` already has a `length` short-circuit. The bridge is what's removable - the snapshot pattern eliminates retaining `savedFileContent` as a full `String` copy in the tab. + +--- + +## 3. `ThemeEngine.shared` lookups in the per-cell hot path + +**Severity:** HIGH (M3 in audit). +**Location:** `TablePro/Views/Results/Cells/DataGridBaseCellView.swift:82, 149, 156, 166, 176, 190, 192, 194` and `TablePro/Views/Results/DataGridCoordinator.swift:519–525`. + +**Current footprint.** + +Per cell `configure(...)`: + +- `applyContent` reads `ThemeEngine.shared.dataGridFonts.regular | .italic | .medium` (one of three branches). +- `applyVisualState` reads `ThemeEngine.shared.colors.dataGrid.deleted | .inserted | .modified` (one branch). + +The singleton is annotated `@Observable` and `@MainActor`. Each `.shared` access is: + +1. A static property fetch (cheap). +2. An Observation registration check - `@Observable` records a read into the current observation transaction. This is the real cost: `_$observationRegistrar.access(self, keyPath: \.dataGridFonts)` adds a tracked dependency, even if no SwiftUI view is observing. For a 30-column visible window scrolling at 60 fps, that's ~9k observation accesses per second purely from cell rendering. +3. A `MainActor.assertIsolated()` no-op in release, but tiny in debug. + +**Target state.** Snapshot fonts and colors before the row loop. The cell already reads `state.visualState` and `content.placeholder`; pass the resolved `NSFont` and `NSColor` through `DataGridCellState` (or a sibling struct). + +```swift +struct DataGridCellPalette { + let fontRegular: NSFont + let fontItalic: NSFont + let fontMedium: NSFont + let rowNumberFont: NSFont + let deletedBg: CGColor + let insertedBg: CGColor + let modifiedBg: CGColor +} +``` + +The coordinator caches a `DataGridCellPalette` and updates it via the existing `themeChanged` Combine pipeline (`DataGridCoordinator.swift:177–183`). Cell render becomes a struct field read - no observation, no actor check. + +**Why this matters more than the `.shared` lookup time.** The `@Observable` registrar uses a per-thread `_AccessList`; under SwiftUI's `update*View` it accumulates dependencies. If the singleton is read from inside `tableView(_:viewFor:row:)`, those dependencies leak into whatever transaction is active. The fix is to keep singletons out of inner-loop reads regardless of measured cost. + +**Apple API references.** + +- *Observation* framework - `@Observable`, `withObservationTracking`, registrar semantics (Swift evolution SE-0395). +- The pattern of "snapshot ambient state to a local before the loop" is the standard advice for `@Observable` types and predates it for `ObservableObject` - every NSTableView render guide since 10.7 has said the same thing. + +--- + +## 4. `UndoManager` retains full row snapshots; no `levelsOfUndo` + +**Severity:** HIGH (M4 in audit). +**Location:** `TablePro/Core/ChangeTracking/DataChangeManager.swift:148–173, 211–369`; `PendingChanges.swift:97–113, 167–171, 277, 300`. + +**Current footprint.** + +- `recordRowDeletion(rowIndex:originalRow: [String?])` retains the full row in the undo closure (line 151). +- `recordBatchRowDeletion(rows: [(Int, [String?])])` retains every row in the batch (line 164). +- Each registered undo block captures `originalRow` by closure, which retains its `[String?]` storage. The matching redo registration in `applyRowDeletionUndo` (line 302) re-captures the same array, doubling the retention until the next stack pop. +- `PendingChanges.changes: [RowChange]` also stores `originalRow` on `RowChange` (used for SQL generation). So a row deletion currently lives in three places: + 1. `pending.changes[i].originalRow` + 2. The undo block's captured `originalRow` + 3. The redo block's captured `originalRow` +- No `levelsOfUndo` cap. macOS default is unlimited; the user can keep editing for hours and accumulate every original row forever. +- `removeAllActions(withTarget:)` is only called on `clearChangesAndUndoHistory()` and `configureForTable()` (lines 91, 107). Switching tabs does not clear it. + +**Target state.** + +1. **Diff storage.** Keep `originalRow` only on `pending.changes[i]` (it is needed for SQL DELETE generation and that path is unavoidable). Do not also capture it in the undo closure - capture the *row index* and look the original up via `pending.change(forRow:type:)`. Cell edits should already be diffs and they are (`CellChange(oldValue:newValue:)`); audit confirms they're fine. The footprint reduction is on `.delete` and `.batchRowDeletion` only. + +2. **Cap stack depth.** + + ```swift + undoManager.levelsOfUndo = 100 + ``` + + `UndoManager.levelsOfUndo` is the Cocoa-native ceiling; setting it drops oldest groups when the count exceeds the limit. Documentation: "Setting the value to 0 (the default) means there is no limit." We're currently at 0. + +3. **Trim per-target.** When the user closes the tab or runs a DDL refresh, call `removeAllActions(withTarget: self)`. The current `releaseData()` in `DataGridCoordinator` does not - verify the tab teardown path clears the change manager's undo stack. + +4. **Group at the right granularity.** `beginUndoGrouping` / `endUndoGrouping` around batch operations; the prompt notes batch deletes register a single undo block which is correct. Make sure cell edits during a paste are grouped too (`automaticallyGroupsByEvent` is on by default). + +**Apple API references.** + +- `UndoManager.levelsOfUndo` - caps the number of groups retained. +- `UndoManager.removeAllActions(withTarget:)` - drops every action whose target is the given object. +- `UndoManager.groupsByEvent` - true by default; one event loop pass = one group. Useful when a single user action records multiple `registerUndo` calls. + +--- + +## 5. JSON highlight regexes are already cached - no fix needed + +**Severity:** N/A (M5 in audit was a false alarm). +**Location:** `TablePro/Views/Results/JSONHighlightPatterns.swift:18–23`. + +The audit suggested `static let regex = try! NSRegularExpression(...)` was not used. It is - the file is already: + +```swift +internal enum JSONHighlightPatterns { + static let string = compileJSONRegex("\"...\"") + static let key = compileJSONRegex(...) + static let number = compileJSONRegex(...) + static let booleanNull = compileJSONRegex(...) +} +``` + +Swift `static let` on a type is lazily initialized once, threadsafe (`dispatch_once` semantics). The audit finding does not apply to current code - leave as is. Cross-reference: confirm callers do not re-instantiate a local `NSRegularExpression`; grep clean. + +--- + +## 6. `var` on semantically immutable fields + +**Severity:** MED (M6 in audit). +**Location:** `TablePro/Models/Query/QueryTabState.swift:255–273`. + +**Current footprint.** + +```swift +struct TabQueryContent: Equatable { + var query: String = "" + var queryParameters: [QueryParameter] = [] + var isParameterPanelVisible: Bool = false + var sourceFileURL: URL? + var savedFileContent: String? + var loadMtime: Date? + var externalModificationDetected: Bool = false + ... +} +``` + +- `sourceFileURL` is set once at file-open and never re-assigned (search for callers confirms; the file URL ties the tab to disk). +- `savedFileContent` is reassigned on save - must remain `var` but should be `private(set)`. Currently it is fully writable from any caller, which makes the dirty calculation unreliable (anyone can stomp it). +- `loadMtime` same shape as `savedFileContent`. + +**Target state.** `let sourceFileURL: URL?`. `private(set) var savedFileContent: String?`. The struct stays `Equatable`. This is not a perf fix; it's a correctness fence that prevents future regressions of the dirty-snapshot invariant proposed in §2. + +**Apple API references.** Standard Swift access control. + +--- + +## 7. `Optional>` in `PaginationState` + +**Severity:** MED (M7 in audit). +**Location:** `TablePro/Models/Query/QueryTabState.swift:103`. + +**Current footprint.** + +```swift +var baseQueryParameterValues: [String?]? +``` + +The outer `?` carries no extra information - `nil` and `[]` are semantically identical here ("no parameters bound for the load-more query"). Flattening: + +```swift +var baseQueryParameterValues: [String?] = [] +``` + +`MemoryLayout<[String?]>.size = 8` (one pointer-sized buffer header). `MemoryLayout<[String?]?>.size = 9` rounds up to 16. Per `PaginationState` instance it's 8 bytes saved - negligible. The win is API hygiene: callers no longer need to write `pagination.baseQueryParameterValues ?? []`. + +**Apple API references.** N/A. + +--- + +## 8. `[String?]` vs `ContiguousArray` for hot row data + +**Severity:** LOW to MED (M8 in audit). +**Location:** `TablePro/Models/Query/Row.swift:20`; `displayCache` value type at `DataGridCoordinator.swift:17`. + +**Current footprint.** + +- `Row.values: [String?]` - `Array` is bridgeable to `NSArray`. The bridging witness adds a check on every Array access: a fast-path `_BridgeStorage` test that distinguishes native Swift storage from a wrapped `_NSArrayCore`. Cells iterate `displayRow.values[columnIndex]` per render; the check is present every time. +- `TableRows.rows: ContiguousArray` - already correct (audit credits this). +- `displayCache` value `[String?]` - same Array vs ContiguousArray distinction. + +**Target state.** + +```swift +struct Row: Equatable, Sendable { + var id: RowID + var values: ContiguousArray +} +``` + +`ContiguousArray` is documented as "the most efficient array type when [the] elements are not class instances or `@objc` protocol types." `String?` is exactly that - `String` is a Swift value type. Bridging cost vanishes. The only change at the call site is the literal `[]` becomes `ContiguousArray()` for empty inits, and pre-allocated arrays use `reserveCapacity` the same way. + +**Sizing.** No size change per element (both are `(Optional, ...)` contiguous storage). The win is purely on access checks and on retention semantics for `_modify` accessors - `ContiguousArray` exposes a stable `withUnsafeMutableBufferPointer` without the bridge dance. + +**Apple API references.** + +- `ContiguousArray` - Standard Library; "do not need to bridge to Objective-C." +- `_modify` accessor - Swift stdlib internal but underpins `Array.subscript`'s in-place mutation; `ContiguousArray` has the same guarantee without the bridge witness. + +--- + +## 9. Broader `NSCache` adoption + +**Severity:** LOW (M9 in audit). +**Locations and proposals.** + +| Cache today | File | Type | Move to | +|---|---|---|---| +| `formatCache: NSCache` | `Core/Services/Formatting/DateFormattingService.swift:28` | already `NSCache` | keep, add `countLimit` if not set | +| `displayCache: [RowID: [String?]]` | `Views/Results/DataGridCoordinator.swift:17` | dict | `NSCache` (see §1) | +| `columnCache: [String: [ColumnInfo]]` | `Core/Autocomplete/SQLSchemaProvider.swift:18` | dict | `NSCache` - schemas are large, rarely re-read after first autocomplete | +| `columnTypeCache: [String: ColumnType]` | `Core/Plugins/PluginDriverAdapter.swift:14` | dict | keep dict - small, bounded by table column count | +| `queryBuildingDriverCache: [String: (any PluginDatabaseDriver)?]` | `Core/Plugins/PluginManager.swift:93` | dict | keep dict - bounded by registered plugin count (~15) | +| `cache: [UUID: [String: PersistedColumnLayout]]` | `Core/Storage/ColumnLayoutPersister.swift:24` | dict | keep - bounded by connection × table count, persisted to disk | +| `lastFiltersCache: [String: [TableFilter]]` | `Core/Storage/FilterSettingsStorage.swift:94` | dict | keep - small | +| `visualStateCache: [VisualStateCacheKey: RowVisualState]` | `Core/SchemaTracking/StructureChangeManager.swift:43` | dict | bounded by changed rows; keep | +| `instances: [UUID: ConnectionDataCache]` | `ViewModels/ConnectionDataCache.swift:13` | strong dict, leaks per closed connection | move to `NSMapTable.weakToWeakObjects()` so closed-connection caches deallocate | +| `querySortCache`, `displayFormatsCache` | `Views/Main/MainContentCoordinator.swift:144, 146` | tab-keyed dicts | guard with `removeValue(forKey:)` on tab close; otherwise persist forever | + +**Apple API references.** + +- `NSMapTable.weakToWeakObjects()` - Foundation; objects deallocate when no other strong reference remains. The map auto-clears the dead entry on next read. +- `NSCache` - see §1. + +The single highest-value addition is `displayCache` from §1. Everything else is a minor tightening. + +--- + +## 10. `PluginQueryResult.rows` - full copy across the bundle boundary + +**Severity:** HIGH (cross-reference D5 in audit, owned by datapath agent). +**Location:** `Plugins/TableProPluginKit/PluginQueryResult.swift:6`. + +**Memory dimension only - the data-path agent owns the streaming protocol design.** + +```swift +public struct PluginQueryResult: Codable, Sendable { + public let rows: [[String?]] + ... +} +``` + +`PluginQueryResult` is a `Sendable` struct returned by value across the plugin bundle boundary. The `rows: [[String?]]` field is the result set in full. Costs: + +1. **Allocation.** Every plugin driver allocates `[[String?]]`. For a 100k-row result with 50 columns, that's 100k inner arrays plus 5M `String?` slots - roughly 200 MB before any cell formatting. +2. **Crossing the boundary.** Swift values cross the bundle boundary by copy. `Array` is COW so the *header* is copied and the storage is retained; this is amortized per result, but the act of returning the struct from the plugin to `PluginDriverAdapter` and onward to `DatabaseManager` still bumps refcounts on each Array header. +3. **Sendable across actors.** `PluginQueryResult` is `Sendable`, so the buffer must be deeply immutable or transferable. Today it is immutable (all `let`), which means the storage is shared by ARC across the boundary - fine, but it also means we *cannot* mutate or chunk in-place; we must allocate a new Array to drop already-consumed rows. + +**Memory ceiling.** With a 16 GB Mac, the practical ceiling is **~10M cells before pressure** (allowing ~200 byte per cell amortized including `String` heap), and **~3M cells before noticeable lag** from GC of `String` storage and from the dictionary overhead of `displayCache`. Above 100k rows, the user's Activity Monitor shows TablePro climbing into the GB range purely from result retention. + +**Target state (memory dimension).** + +- Plugin returns a `PluginQueryStream` protocol that emits `PluginRowChunk(rowsAffected:offset:rows:)` of bounded size. The Swift side accumulates into a streaming store modeled on Sequel-Ace's `SPDataStorage` - see audit §3.2. +- The grid's view of the store is a sliding window; only the "visible-rect rows ± preWarm" range is converted to display strings. +- Sequel-Ace caches `IMP` (method pointers) to avoid Objective-C messaging overhead in the cell access path. The Swift equivalent is to expose the store via a non-resilient protocol or pre-resolved closure (`@inlinable` is *not* available across module boundaries for non-frozen types; use a closure stored on the coordinator). + +**Apple API references.** + +- `Sendable` and `@Sendable` closures - Swift Concurrency Manifesto / SE-0302. +- `MemoryLayout.size = 16` (8 byte tag + 8 byte storage pointer) on 64-bit; useful when sizing the streaming chunk. +- `OSAllocatedUnfairLock` - for the streaming store's row-array guard if it must be read from non-main contexts. Apple's recommended replacement for `os_unfair_lock_t` since macOS 13: zero-overhead in the uncontended fast path, no priority inversion. + +**Result-set memory ceiling worked example.** + +For a typical query result: + +- average value width: 32 chars UTF-8 +- `MemoryLayout.stride` (small string optimization): 16 bytes inline, 0 heap for ≤15-byte strings; otherwise 16 bytes + heap `_StringStorage` (rounded to 32 bytes). +- columns: 20 + +Per row in RAM: `20 × 16 byte slot + 20 × ~48 byte heap = 1.28 KB`. +- 10k rows: ~12 MB - fine. +- 100k rows: ~125 MB - degraded scroll. +- 1M rows: ~1.25 GB - unusable. +- The grid's `displayCache` (§1) at the same row count adds another 1×. Total for 1M rows: ~2.5 GB. + +The streaming proposal caps the resident set to `pageSize × column count` regardless of underlying result size. + +--- + +## 11. Reference counting in inner loops - `rowVisualStateCache` and friends + +**Severity:** MED (audit M3 partial). +**Location:** `TablePro/Views/Results/DataGridCoordinator.swift:112, 533–584`. + +**Current footprint.** + +```swift +private var rowVisualStateCache: [Int: RowVisualState] = [:] +``` + +`RowVisualState` is a struct with three fields: + +```swift +struct RowVisualState { + let isDeleted: Bool + let isInserted: Bool + let modifiedColumns: Set +} +``` + +`Set` is a class-backed COW container. Reading `rowVisualStateCache[row] ?? .empty` (line 583) is fine - the dictionary lookup is O(1), the value is a struct copy, but `Set` retains its underlying storage on copy. + +For visible-row rendering, `visualState(for:)` is called per cell, which is per row × per column. For a 30-column visible window, that's ~30 retains/releases per row per scroll frame on the `Set` storage - not catastrophic, but avoidable. + +**Target state.** + +- For the common case (no modified columns), use a sentinel: `static let empty` is already there. Confirm `RowVisualState.empty` is shared across all callers (it is - `static let`), so the fast-path retain hits a single global. +- For rows with modified columns, replace `Set` with a small inline storage: a 64-bit bitmap if the column count is ≤64, otherwise a `ContiguousArray` bitmap. The grid's column count is bounded by the table schema - most tables have <64 columns. + +```swift +struct ColumnSet { + private var bits: UInt64 + private var overflow: ContiguousArray? + func contains(_ column: Int) -> Bool { ... } + mutating func insert(_ column: Int) { ... } +} +``` + +This is a value type with no heap escape for tables ≤64 columns. The `displayCache` invalidation path that calls `state.visualState.modifiedColumns.contains(state.columnIndex)` (`DataGridBaseCellView.swift:193`) becomes a single AND. + +**Apple API references.** + +- `MemoryLayout>.stride = 8` (one pointer); the heap allocation behind it is the cost. +- `MemoryLayout.stride = 8` - same slot size, zero heap for ≤64-column tables. +- `ContiguousArray.withUnsafeMutableBufferPointer` for the overflow path - keeps the bitmap dense. + +This is opportunistic. If the grid is otherwise tuned (per §1, §3), `Set` is fine. List it as a tail optimization once the bigger items are paid down. + +--- + +## 12. `_columnsStorage` defensive copy + +**Severity:** LOW (cleanup). +**Location:** `TablePro/Core/ChangeTracking/DataChangeManager.swift:59–63`. + +```swift +private var _columnsStorage: [String] = [] +var columns: [String] { + get { _columnsStorage } + set { _columnsStorage = newValue.map { String($0) } } +} +``` + +The `.map { String($0) }` copies every column name. Swift `String` is COW - assignment alone retains storage; the explicit `String($0)` initializer forces a fresh `_StringStorage` (it goes through `init(_ other: String)` which on native strings is a no-op copy of the pointer, but for bridged NSStrings forces a deep copy). + +Unless this guard exists to defend against incoming bridged NSStrings, it's redundant - and we now own all column names, since they come from the plugin's `PluginQueryResult.columns: [String]` which is already a Swift `[String]`. + +**Target state.** + +```swift +var columns: [String] = [] +``` + +Drop the underscore field and the map. Saves ~`column count × ~48 byte` per `configureForTable` call on a large schema, plus the time to re-init each `String`. + +If there's a known bug behind this defense, document it in a comment at the field - but per CLAUDE.md, the comment should explain *why* (the historical NSString issue), not *what*. + +--- + +## 13. Summary table (severity × footprint × Apple API) + +| # | Sev | Item | File:line | Fix | Apple API | +|---|---|---|---|---|---| +| §1 | CRIT | `displayCache` unbounded | `DataGridCoordinator.swift:17` | `NSCache` w/ countLimit + totalCostLimit | `NSCache`, `NSDiscardableContent` | +| §2 | HIGH | `isFileDirty` bridges per keystroke | `QueryTabState.swift:266` | `(byteCount, hash)` snapshot at save | `String.utf8.count`, `Hasher` | +| §3 | HIGH | `ThemeEngine.shared` 4×/cell | `DataGridBaseCellView.swift:149,156,166,176,190,192,194` | snapshot palette before row loop | Observation registrar semantics | +| §4 | HIGH | UndoManager retains rows + no cap | `DataChangeManager.swift:148–173` | diff capture + `levelsOfUndo = 100` | `UndoManager.levelsOfUndo`, `removeAllActions(withTarget:)` | +| §5 | n/a | regex already cached | `JSONHighlightPatterns.swift:18` | none | static let dispatch_once semantics | +| §6 | MED | `var` on immutable fields | `QueryTabState.swift:259, 260` | `let` / `private(set) var` | access control | +| §7 | MED | `Optional>` flatten | `QueryTabState.swift:103` | `[String?] = []` | n/a | +| §8 | MED | `Row.values` Array → ContiguousArray | `Row.swift:20` | `ContiguousArray` | `ContiguousArray` | +| §9 | LOW | broader `NSCache` adoption | various | per-item table | `NSCache`, `NSMapTable.weakToWeakObjects` | +| §10 | HIGH | `PluginQueryResult.rows` full copy | `PluginQueryResult.swift:6` | streaming chunked protocol (datapath agent owns) | `Sendable`, `OSAllocatedUnfairLock` | +| §11 | MED | `Set` retain in cell loop | `DataGridView.swift:17–20` | inline bitmap `ColumnSet` | `MemoryLayout`, `ContiguousArray` | +| §12 | LOW | `_columnsStorage` defensive copy | `DataChangeManager.swift:59–63` | drop the wrapper | n/a | + +--- + +## 14. Result-set memory ceiling - operator guidance + +Use these as upper bounds before degradation, given today's architecture (no streaming, full result resident, `displayCache` unbounded): + +| rows × cols | resident before fix | resident after §1 + §10 | +|---|---|---| +| 10k × 20 | ~25 MB | ~25 MB (no change - under cap) | +| 100k × 20 | ~250 MB | ~50 MB (cap + window) | +| 1M × 20 | ~2.5 GB (paging, lag) | ~50 MB (cap + window) | +| 100k × 100 | ~1.25 GB | ~150 MB (cap + window) | + +The `NSCache` `totalCostLimit` of 32 MB in §1 is the durable defense; the streaming store in §10 is what makes the underlying result not need to be resident. + +--- + +## 15. Out of scope for this audit + +- Layer/render and `wantsLayer` (§2.1, layer agent). +- `reloadData()` overuse and incremental updates (§2.3, NSTableView agent). +- Threading and Combine debounce (§2.4, threading agent). +- SwiftUI/AppKit interop diff (§2.5, interop agent). + +These cross at §1 (cache size affects scroll cost) and §10 (streaming affects threading). Cross-references noted; no work duplicated. + +--- + +*End of memory audit. All Apple APIs cited are macOS 14.0+ (TablePro deployment target). No code modified.* diff --git a/docs/refactor/datagrid-native-rewrite/08-custom-vs-native.md b/docs/refactor/datagrid-native-rewrite/08-custom-vs-native.md new file mode 100644 index 000000000..b5f372cd1 --- /dev/null +++ b/docs/refactor/datagrid-native-rewrite/08-custom-vs-native.md @@ -0,0 +1,244 @@ +# 08 - Custom code that should be native + +Sweep of the TablePro codebase for custom abstractions where AppKit, Foundation, SwiftUI, Combine, or system frameworks already provide the primitive. Each row names file:line, what is reimplemented, and the exact Apple API replacement (with SDK header reference). Some entries note that the native API is the *wrong* fit and the custom code stays. + +Severity legend: **HIGH** = clear native replacement, drop-in; **MED** = native fit with caveats; **LOW** = custom code is justified, document the reasoning; **N/A** = native already in use, audit row stale. + +--- + +## 1. Pre-existing audit findings (N1–N5) - re-evaluated + +### N1. `ConnectionDataCache` instance dictionary + +- **File:** `TablePro/ViewModels/ConnectionDataCache.swift:13` +- **What it reimplements:** Per-connection multiton: `private static var instances: [UUID: ConnectionDataCache] = [:]`. The audit calls this a "manual cache dict" and suggests `NSCache`. +- **Audit recommendation is wrong.** This is not a value cache - it stores a long-lived `@Observable` view model that SwiftUI views subscribe to via `@Bindable`. `NSCache` (`Foundation/NSCache.h`) evicts entries under memory pressure or when its `countLimit`/`totalCostLimit` is hit, which would silently invalidate active SwiftUI bindings and detach Combine subscriptions in `cancellables`. That is a correctness bug, not a perf win. +- **Correct native API:** `NSMapTable` constructed with `.strongMemory` keys and `.weakMemory` values (`Foundation/NSMapTable.h`). Entries auto-clear when no SwiftUI view holds the cache, no eviction under pressure. Alternative: keep the dictionary but add removal in `deinit` (not on a class with `deinit` cancelling its own task - it would be the *last* user of that ID). +- **Severity:** LOW. Current dict leaks one cache per ever-opened connection until app quit. Memory cost is small (folder/favorite arrays). `NSMapTable` is the right primitive but not urgent. + +### N2. `CopilotIdleStopController` Task.sleep debounce + +- **File:** `TablePro/Core/AI/Copilot/CopilotIdleStopController.swift:48–58` +- **What it reimplements:** Manual `Task { try await Task.sleep(for: timeout); … }` that re-checks predicates and fires `onStopRequest`. Schedule cancels the prior task before starting a new one. +- **Native API:** Combine `Publishers.Debounce` (`Combine/Combine.h`, `Combine/Publishers+Debounce.swift`) on a `PassthroughSubject`. The pattern: send a tick on `schedule()`, debounce by `timeout`, sink fires the predicate-gated stop. Or `AsyncSequence.debounce(for:)` from `swift-async-algorithms`. Both handle "newest input wins" with a single subscription, no manual task lifecycle. +- **Caveat:** Combine's debounce schedules on a `Scheduler` (e.g. `DispatchQueue.main`), so the predicate re-checks must still be `@MainActor`-aware. The current code is already `@MainActor` so the migration is straightforward. +- **Severity:** LOW. Custom is ~25 lines, Combine equivalent ~10 lines. Behaviour identical. Worth doing for consistency with `InlineSuggestionManager` and `SQLCompletionAdapter`, both of which already use Combine. + +### N3. `DateFormattingService` cached `DateFormatter` + +- **File:** `TablePro/Core/Services/Formatting/DateFormattingService.swift:18–123` +- **What it reimplements:** Singleton wrapping a primary `DateFormatter` (output) plus six `DateFormatter` parsers (input formats) plus an `NSCache` for parsed-format memoization. +- **Native API for new code:** `Date.FormatStyle` (Foundation, macOS 12+, declared in `` and `Foundation/Date+FormatStyle.swift` in the open-source Foundation layout). Build once with `Date.FormatStyle(date:.numeric, time:.standard).locale(.current).timeZone(.current)`, call `style.format(date)`. Parses via `try Date("…", strategy: .iso8601)` or `Date.ParseStrategy`. The format style is a value type, copy-on-write, eliminates the locked formatter pattern entirely. +- **Caveat:** TablePro supports six legacy database formats (`yyyy-MM-dd HH:mm:ss`, etc.). `Date.ParseStrategy` covers ISO 8601 cleanly via `.iso8601`; the MySQL-style naive timestamps need a custom `Date.ParseStrategy` or fall back to `Date.VerbatimFormatStyle` for parsing. Migration is per-format, not all-or-nothing. +- **Note on the existing `NSCache`:** the cache is correct (`Foundation/NSCache.h`), bounded at 10k entries, cleared on format change. Keep it. Migration replaces the `DateFormatter` instances, not the cache. +- **Severity:** LOW. Performance is fine (cached). Migration is a code-style win, not a perf win. + +### N4. `FuzzyMatcher` custom scorer + +- **File:** `TablePro/Core/Utilities/UI/FuzzyMatcher.swift:15–92` +- **What it reimplements:** Subsequence fuzzy match with weighted bonuses (consecutive run, word boundary, camelCase break, position, length ratio). Caller: `QuickSwitcherViewModel.swift:161`. +- **Native API for *simple* matching:** `String.localizedStandardCompare(_:)` (`Foundation/NSString.h` declares `localizedStandardCompare:` since 10.6) is Finder-style: case-insensitive, diacritic-insensitive, locale-aware, with natural numeric ordering. Or `String.range(of:options: [.caseInsensitive, .diacriticInsensitive])` for substring containment. +- **Custom is justified.** A quick switcher needs subsequence matching ("`rdb`" → "`r`ed`b`lack`d`atabase") and ranking by run length and word-boundary hits, which `localizedStandardCompare` does not do. `NSPredicate` `MATCHES` regex would not produce a numeric score for ranking. Apple's `Spotlight` ranking uses `MDItem`s and is not exposed for in-app strings. +- **Severity:** LOW (no change). Document that fuzzy ranking is an intentional custom path. Do not replace. + +### N5. `ResponderChainActions` documentation protocol + +- **File:** `TablePro/Core/KeyboardHandling/ResponderChainActions.swift` +- **What it reimplements:** Nothing. The file is an `@objc` protocol whose only purpose is to centralize selector names for `NSApp.sendAction(_:to:from:)` so every responder-chain action used in the app has a single declaration site. The protocol carries `@objc optional func` declarations; no class implements it. +- **Native idiom check.** This is the textbook AppKit pattern: define `@objc` selectors, send via `NSApp.sendAction(#selector(Foo.bar(_:)), to: nil, from: nil)`, validate via `NSUserInterfaceValidations`. Apple uses this exact shape in `NSResponder.h` (``) for `copy:`, `paste:`, `cut:`, `selectAll:`, `cancelOperation:`, `delete:`, etc. Verified no shortcut interception - `TableProApp.swift` `.commands { … }` calls `NSApp.sendAction` directly without bypassing validation. +- **Severity:** N/A. Already correct. Keep the protocol-as-doc pattern. + +--- + +## 2. Additional findings discovered during sweep + +### A1. `ColumnVisibilityPopover` SwiftUI list + +- **File:** `TablePro/Views/Results/ColumnVisibilityPopover.swift:27–103` +- **What it reimplements:** A 260pt-wide popover with a search field plus a `List` of `Toggle(.checkbox)` rows, presented from a toolbar/header button. Bound through closure callbacks. +- **Native API:** `NSTableView.headerView?.menu` (`AppKit/NSTableHeaderView.h`, `AppKit/NSView.h` for `menu`). Right-clicking a column header shows the menu; toggling a `NSMenuItem` with `.state = .on/.off` hides/shows the column via `NSTableColumn.isHidden` (since 10.5). Apple's Mail and Finder use this exact pattern. For "Show All / Hide All", add separator items above the column list. +- **Caveat:** The current popover supports a search field for tables with many columns (>5 trigger). `NSMenu` does not support inline search; for >40-column tables the menu becomes unwieldy. A practical pattern is: short-list (<40) → `NSMenu`; long-list → keep the popover. Or use `NSMenu` always plus an NSWindow-backed "Choose Columns…" sheet for power users. +- **Severity:** MED. Drop-in for the common case; keep popover for wide tables. Either way, expose the menu on the table header so right-click works as users expect. + +### A2. `ResultTabBar` SwiftUI horizontal tab strip + +- **File:** `TablePro/Views/Results/ResultTabBar.swift:11–104` +- **What it reimplements:** A horizontal `ScrollView(.horizontal)` of `Button`-backed tabs with active tint, hover background, pin/close affordances, and contextual menu (Pin / Close / Close Others). Used to switch between multiple result sets returned by a single query. +- **Native API options:** + - `NSTabViewController` (``, since 10.10) with `tabStyle = .toolbar` or `.segmentedControlOnTop`. Manages segmented selection, view swapping, and animation. Does *not* support pin or in-tab close affordances. + - `NSSegmentedControl` (``) with `.segmentStyle = .texturedRounded`, `.trackingMode = .selectOne`. Same - no per-segment close. + - **Custom is justified for the close/pin affordances.** `NSTabViewController` and `NSSegmentedControl` do not expose per-tab "x" buttons or pin glyphs. Sequel Ace, Xcode, and Safari all build custom tab bars for the same reason. +- **Severity:** LOW. Document the choice. The current SwiftUI implementation is fine; its only weakness vs `NSTabViewController` is no built-in keyboard navigation (Ctrl+Tab to cycle). Add `.keyboardShortcut` to the activate button or an `onKeyPress` handler. + +### A3. `EditorTabBar` referenced in CLAUDE.md but does not exist + +- CLAUDE.md says "`EditorTabBar` - pure SwiftUI tab bar". Verified via `grep -rn "struct EditorTabBar"` in `TablePro/Views/Editor/` - no such symbol. Editor tabs are now native NSWindow tabs (`NSWindow.tabbingMode`, ``) per `WindowManager.swift` and `MainContentView+Setup.swift`. Already correct. +- **Severity:** N/A. Update CLAUDE.md to drop the stale reference. + +### A4. `CellOverlayEditor` borderless `NSPanel` + +- **File:** `TablePro/Views/Results/CellOverlayEditor.swift:97–123, 215–224` +- **What it reimplements:** A floating, non-activating, borderless `NSPanel` containing an `NSScrollView` + `NSTextView`, anchored to a cell rect, used for multi-line cell editing. Custom panel subclass overrides `canBecomeKey` and forwards `resignKey` to a closure. `NSTextView` subclass intercepts Cmd+S `performKeyEquivalent`. +- **Native API check:** This *is* the correct primitive. `NSPanel` (``) with `.nonactivatingPanel` style is exactly what Pages/Numbers/Xcode use for floating editors. The audit's suggestion of "standard NSText field editor + custom NSTextView for multi-line via `windowWillReturnFieldEditor:to:`" applies to *single*-line cell editing; multi-line needs the scrollable editor that `NSCell.fieldEditor` cannot provide (no vertical scroll, no multi-line layout in a `NSTextField` cell). +- **Note:** Sequel Ace uses the same pattern (`AppKitDataGrid.swift` from Gridex floats an editor on the table view, audit §3.1). +- **Severity:** N/A. Custom is correct. Keep. + +### A5. `SortableHeaderView` custom click-cycle and indicator + +- **File:** `TablePro/Views/Results/SortableHeaderView.swift:84–287` +- **What it reimplements:** Subclass of `NSTableHeaderView` that intercepts `mouseDown:`, runs `HeaderSortCycle.nextTransition` (asc → desc → clear, plus shift+click multi-sort), and writes sort indicator state into a custom `SortableHeaderCell`. Also handles cursor rects for resize zones. +- **Native API check:** The audit suggests `NSTableColumn.sortDescriptorPrototype` (``, since 10.3). `sortDescriptorPrototype` + `tableView.sortDescriptors` + `NSTableViewDelegate.tableView(_:sortDescriptorsDidChange:)` is the documented way to drive single-column sort indicators with the standard chevron glyph; AppKit handles asc/desc toggle and indicator drawing via `NSTableView.indicatorImage(in:)`. +- **Custom is justified.** Three reasons: + 1. **Multi-column sort with priority numbers.** AppKit shows only one sort chevron at a time; multi-column needs custom drawing of "1↑ 2↓" badges. `NSSortDescriptor` array supports multi-key, but the indicator UI does not. + 2. **Click-cycle with clear-on-third-click.** AppKit's default cycle is asc → desc → asc. TablePro's three-state cycle (asc → desc → cleared) requires intercepting `mouseDown:`. + 3. **Server-side sort dispatch.** Sort needs to issue a SQL `ORDER BY` round-trip to the database, not sort an `NSArrayController`. Custom dispatch is unavoidable. +- **Severity:** N/A. Document why. Possible micro-cleanup: when sort is single-column, fall back to `NSTableColumn.sortDescriptorPrototype` for the indicator drawing only (still custom for dispatch). Probably not worth the divergence. + +### A6. `FilterPanelView` custom WHERE-clause builder + +- **File:** `TablePro/Views/Filter/FilterPanelView.swift:8–248` +- **What it reimplements:** SwiftUI form rendering rows of `(column, operator, value, [secondValue])`, AND/OR mode picker, preset save/load, raw-SQL escape hatch. +- **Native API:** `NSPredicateEditor` (``, since 10.5) with `NSPredicateEditorRowTemplate`s. +- **Native is the wrong fit.** Three blockers: + 1. `NSPredicateEditor` produces an `NSPredicate`. TablePro emits *SQL strings* via `quoteIdentifier`-aware driver methods (different per dialect: MySQL backticks vs Postgres double-quotes vs MSSQL brackets). Translating `NSPredicate` to dialect-specific SQL requires a full predicate visitor; there is no Apple API for that. + 2. The operator vocabulary differs. TablePro uses `CONTAINS` (LIKE-wrapped), `IN`, `BETWEEN`, `REGEX`, `IS NULL` distinct from `IS EMPTY`. `NSPredicate` has CONTAINS but not the LIKE-with-leading/trailing-wildcard distinction or BETWEEN with two scalar inputs (you must compose `>= AND <=`). + 3. Raw-SQL escape (`__RAW__` column) cannot be expressed in `NSPredicateEditor`'s row templates at all. +- **Severity:** N/A. Custom is correct. Keep. + +### A7. `QuickSwitcherSheet` SwiftUI `.sheet` + +- **File:** `TablePro/Views/QuickSwitcher/QuickSwitcherView.swift:14–249` +- **What it reimplements:** Search-then-list overlay shown via SwiftUI `.sheet`. Spotlight-style. +- **Native API:** Borderless non-activating `NSPanel` (``) with `.styleMask = [.borderless, .nonactivatingPanel, .titled, .fullSizeContentView]`, `.level = .floating`, `.collectionBehavior = [.canJoinAllSpaces, .moveToActiveSpace]`. This is what Spotlight, Raycast, and Alfred use; the panel does not steal focus from the previous app, dismisses on `resignKey`, and hosts a SwiftUI view via `NSHostingController`. +- **Caveat:** SwiftUI `.sheet` attaches to the parent window and cannot float over it the way Spotlight does. The current implementation works but feels less "command-palette-like" than a floating panel. The team has already accepted that native sheets are the project standard ([Active Sheet pattern, see comment at file:13]). Migration is optional, not required. +- **Severity:** MED. If the goal is Spotlight-like presentation (always-on-top, overlay style), migrate to NSPanel via `NSHostingController`. Otherwise leave as `.sheet`. + +### A8. `EnumPopoverContentView` searchable enum picker + +- **File:** `TablePro/Views/Results/EnumPopoverContentView.swift:12–99` +- **What it reimplements:** SwiftUI `List` with a `NativeSearchField` header and a NULL-marker first row. Used for `ENUM`-type cells (MySQL ENUM, Postgres CHECK enums). +- **Native API:** `NSPopUpButton` (``) for static lists, or `NSComboBox` (``) for searchable+typeable lists. `NSPopUpButton` is non-searchable and becomes unusable past ~30 items. `NSComboBox` is searchable but combines text-entry with selection, which is wrong for ENUM (must be one of the predefined values, not free text). +- **Custom is justified.** The "search-then-pick" UX with a fixed value list is what `NSPopUpButton` *should* support but doesn't. The closest native primitive is the popover-table pattern Xcode uses for "Run Destinations" - which is itself custom (popover hosting an `NSTableView`). Either way the win from going pure-AppKit here is small. +- **Severity:** LOW. Keep custom. If a future macOS version adds a searchable popup button (rumored for SwiftUI's `Picker(searchable:)`), revisit. + +### A9. `ForeignKeyPopoverContentView` searchable FK picker + +- **File:** `TablePro/Views/Results/ForeignKeyPopoverContentView.swift:12–192` +- **What it reimplements:** SwiftUI `List` (with async DB fetch of up to 1000 referenced rows + display column) inside an `NSPopover`. +- **Native API:** Same conclusion as A8 - `NSComboBox` allows free text entry which corrupts FK values; `NSPopUpButton` does not search. Apple's `NSPopover` (``) is already what hosts this view. The custom part is the `List` body, which is appropriate. +- **Caveat:** The async fetch runs on `.task` and does not cancel if the popover dismisses mid-flight. Verify cancellation is wired (out of scope for this task). +- **Severity:** LOW. Keep. Same reasoning as A8. + +### A10. `UnifiedRightPanelView` inspector picker + content switch + +- **File:** `TablePro/Views/RightSidebar/UnifiedRightPanelView.swift:8–157` +- **What it reimplements:** A SwiftUI tab picker + history menu + new-conversation button, hosted *inside* an already-native `NSSplitViewItem(inspectorWithViewController:)` (see `MainSplitViewController.swift:144`). Switches between Details / AI Chat panes via internal state. +- **Native API:** The container is already correct - `NSSplitViewItem.behavior == .inspector` is in use. The audit's suggestion of `NSSplitViewItem.behavior = .inspector` or SwiftUI `.inspector` (macOS 14+) refers to the container, which TablePro already does natively. +- **The custom part** is the inspector's *internal* tab switcher. The natural alternative is a per-tab `NSToolbarItem` with `NSSegmentedControl` placed in the window toolbar (Mail's "Reply / Reply All / Forward" toolbar split). This is the convention for inspector-internal navigation in Apple apps. Migrating means moving the tab picker out of the inspector content and into the window toolbar - coupled with toolbar redesign, larger scope. +- **Severity:** N/A for the container, MED for the in-panel picker. Defer until toolbar redesign. + +### A11. `RedisKeyTreeView` SwiftUI `DisclosureGroup` tree + +- **File:** `TablePro/Views/Sidebar/RedisKeyTreeView.swift:42–66` +- **What it reimplements:** Recursive SwiftUI `DisclosureGroup` tree of namespaces and Redis keys. Children are loaded eagerly (passed in via `nodes`), expansion state lives in `expandedPrefixes: Set`. +- **Native API:** `NSOutlineView` (``) with `NSOutlineViewDataSource` and lazy `outlineView(_:numberOfChildrenOfItem:)` / `outlineView(_:child:ofItem:)`. View-based outline view (since 10.7) has the same cell-reuse story as `NSTableView`. Lazy expansion only fires the data-source children query when a row is expanded. +- **Migration is justified at scale.** The current code accepts up to 50,000 keys (file:34 message). At that scale `DisclosureGroup` builds the entire tree synchronously on every `body` invocation; `NSOutlineView` queries lazily and can scroll through 100k items at 60fps with cell reuse. This is the same root cause as the data grid's scroll lag - SwiftUI `List` / `DisclosureGroup` is fine for ~100 rows, painful past ~1000. +- **Severity:** HIGH for large Redis databases, LOW for typical use. Wrap in `NSViewRepresentable` of `NSOutlineView` if Redis users hit lag at scale. Separate decision from the main data grid rewrite. + +### A12. `PopoverPresenter` triple-nest helper + +- **File:** `TablePro/Views/Components/PopoverPresenter.swift:11–34` +- **What it reimplements:** Three-line helper that builds an `NSPopover`, sets an `NSHostingController` as `contentViewController`, and presents it relative to a view rect. Returns the popover for caller-managed dismissal. +- **Native API:** This *is* the native API. The "triple nest" the brief refers to is `NSPopover { contentViewController = NSHostingController(rootView: …) }` - three lines, not a custom abstraction. The helper deduplicates those three lines plus a `[weak popover]` dismiss closure. +- **Severity:** N/A. The helper is pure ergonomics over `NSPopover` (``). Keep. + +--- + +## 3. Surfaced beyond the brief - additional custom abstractions + +### B1. Multiton-style `static var instances` caches across ViewModels + +- **Pattern:** `static var instances: [UUID: T] = [:]` plus a `shared(for:)` lookup. +- **Where:** `ConnectionDataCache.swift:13` (covered above). Grep `static var instances:` shows this is the only instance. +- **Native API:** `NSMapTable` with weak values, as in N1. + +### B2. `DispatchQueue.main.asyncAfter` used as one-shot timer + +- **Files (search hit list):** + - `TablePro/Views/Terminal/TerminalTabContentView.swift` + - `TablePro/Views/Results/JSONSyntaxTextView.swift` + - `TablePro/Views/Results/HexEditorContentView.swift` + - `TablePro/Views/Results/ResultsJsonView.swift` + - `TablePro/Core/SSH/SSHMatchExecutor.swift` +- **Native API:** For one-shot delays in `@MainActor` code, `Task { try await Task.sleep(for: .milliseconds(n)); … }` is more cancelable than `asyncAfter`. For repeating debounce, `Combine.debounce` (see N2). For run-loop-bound timers, `Timer.scheduledTimer(withTimeInterval:repeats:block:)` (``). +- **Severity:** LOW each. Per-file evaluation needed; some `asyncAfter` calls are genuinely fire-and-forget (e.g. delaying first-responder focus by a runloop tick), where AppKit's `RunLoop.main.perform { … }` or `NSAnimationContext.runAnimationGroup` is the right primitive. + +### B3. Manual `NSCache` is sparse - only one instance + +- Grep shows exactly one `NSCache` in the app codebase: `DateFormattingService.swift:28`. Any future work that needs a bounded LRU should use `NSCache` (``) rather than rolling a Swift dict + size check. + +### B4. `NativeSearchField` already wraps `NSSearchField` + +- **File:** `TablePro/Views/Sidebar/NativeSearchField.swift` +- **Status:** Already native (`NSSearchField` from `` via `NSViewRepresentable`). No action. + +### B5. No custom progress spinners or path bars detected + +- Grep for `Circle().stroke / trim(from:to:)` patterns produced only disclosure-rotation effects, not custom spinners. `ProgressView()` is used app-wide (SwiftUI `ProgressView` resolves to `NSProgressIndicator` on macOS, ``). +- No `NSPathControl` reimplementations found. +- No custom tooltip implementations found - `NSView.toolTip` (``) and SwiftUI `.help(_:)` are used throughout. + +### B6. `CopilotIdleStopController` is not the only Task.sleep debounce + +Files with `Task.sleep` that might be debounce-shaped (sample, not exhaustive - full audit in task #4): + +- `TablePro/Core/Services/Licensing/LicenseManager.swift` +- `TablePro/Core/AI/InlineSuggestion/InlineSuggestionManager.swift` +- `TablePro/Core/AI/Copilot/CopilotAuthManager.swift` +- `TablePro/Core/AI/Copilot/CopilotService.swift` +- `TablePro/Core/Services/Infrastructure/SessionStateFactory.swift` +- `TablePro/Core/Services/Infrastructure/PreConnectHookRunner.swift` +- `TablePro/Core/Sync/SyncCoordinator.swift` +- `TablePro/Core/Database/ConnectionHealthMonitor.swift` + +Each needs case-by-case classification (debounce vs. retry-with-backoff vs. health-check timer). Combine `.debounce` / `.throttle` only fits debounce, not health checks (use `Timer.publish(every:on:in:).autoconnect()` from `` via Combine bridge). + +--- + +## 4. Summary table + +| ID | File:Line | Reimplements | Native API | Header | Severity | Action | +|----|-----------|--------------|-----------|--------|----------|--------| +| N1 | `ConnectionDataCache.swift:13` | Strong-ref multiton dict | `NSMapTable` weak values (NOT `NSCache`) | `Foundation/NSMapTable.h` | LOW | Migrate when convenient | +| N2 | `CopilotIdleStopController.swift:48` | Task.sleep debounce | `Combine.debounce` | `Combine/Combine.h` | LOW | Migrate for consistency | +| N3 | `DateFormattingService.swift:18` | `DateFormatter` parsers | `Date.FormatStyle` / `Date.ParseStrategy` | `Foundation/FormatStyle.h` | LOW | Migrate new code only | +| N4 | `FuzzyMatcher.swift:15` | Subsequence scorer | `localizedStandardCompare` (insufficient) | `Foundation/NSString.h` | LOW | Keep custom | +| N5 | `ResponderChainActions.swift` | Selector documentation | `NSResponder` selectors (already used) | `AppKit/NSResponder.h` | N/A | Keep | +| A1 | `ColumnVisibilityPopover.swift` | Popover toggles list | `NSTableHeaderView.menu` + `NSTableColumn.isHidden` | `AppKit/NSTableHeaderView.h` | MED | Header right-click menu, keep popover for wide tables | +| A2 | `ResultTabBar.swift` | Closeable/pinnable tab strip | `NSTabViewController` (no per-tab close) | `AppKit/NSTabViewController.h` | LOW | Keep custom | +| A3 | `EditorTabBar` (gone) | - | `NSWindow.tabbingMode` (already used) | `AppKit/NSWindow.h` | N/A | Update CLAUDE.md | +| A4 | `CellOverlayEditor.swift` | Floating multi-line editor panel | `NSPanel.nonactivatingPanel` (already used) | `AppKit/NSPanel.h` | N/A | Keep | +| A5 | `SortableHeaderView.swift` | Multi-column sort header | `NSTableColumn.sortDescriptorPrototype` (single-column only) | `AppKit/NSTableColumn.h` | N/A | Keep custom (multi-sort) | +| A6 | `FilterPanelView.swift` | SQL WHERE builder | `NSPredicateEditor` (wrong fit, NSPredicate not SQL) | `AppKit/NSPredicateEditor.h` | N/A | Keep custom | +| A7 | `QuickSwitcherView.swift` | Search-and-pick sheet | Borderless `NSPanel` (Spotlight-style) | `AppKit/NSPanel.h` | MED | Optional migration for floating UX | +| A8 | `EnumPopoverContentView.swift` | Searchable enum picker | `NSPopUpButton` (no search) / `NSComboBox` (allows free text) | `AppKit/NSPopUpButton.h` | LOW | Keep | +| A9 | `ForeignKeyPopoverContentView.swift` | Searchable FK picker | `NSPopover` + `NSTableView` (already done) | `AppKit/NSPopover.h` | LOW | Keep, verify task cancellation | +| A10 | `UnifiedRightPanelView.swift` | Inspector internal tab picker | Container is `NSSplitViewItem.inspector` (already), picker should move to window `NSToolbarItem` | `AppKit/NSSplitViewItem.h` | MED | Defer to toolbar redesign | +| A11 | `RedisKeyTreeView.swift` | Recursive `DisclosureGroup` tree | `NSOutlineView` | `AppKit/NSOutlineView.h` | HIGH | Migrate for >5k-key databases | +| A12 | `PopoverPresenter.swift` | NSPopover construction helper | `NSPopover` (this *is* it) | `AppKit/NSPopover.h` | N/A | Keep | +| B1 | (above) | Multiton dict pattern | `NSMapTable` | `Foundation/NSMapTable.h` | LOW | One instance only | +| B2 | 5 files | `asyncAfter` as timer | `Task.sleep` / `Timer.scheduledTimer` / `Combine.debounce` | `Foundation/NSTimer.h` | LOW | Per-file review | + +--- + +## 5. Recommended priority order + +1. **A11 (Redis outline view).** Only HIGH-severity item. Same root cause as the data grid scroll-lag thesis - SwiftUI list-of-N collapses past ~1k rows. Wrap `NSOutlineView` in `NSViewRepresentable`. +2. **A1 (column visibility menu).** MED, drop-in for the common case. Restore the right-click-on-header convention users expect from Finder/Mail. +3. **N2 (Combine debounce).** Code-style win, removes ~25 lines, brings Copilot in line with `InlineSuggestionManager` / `SQLCompletionAdapter`. +4. **N1 (NSMapTable).** Quiet correctness fix. No user-visible change today. +5. **N3 (Date.FormatStyle).** Tag as "for new code"; do not rewrite existing usages. +6. **A7, A10.** Defer to UX-led work (Spotlight-style command palette; toolbar redesign). +7. **B2.** Audit each `asyncAfter` site individually; classify before changing. + +Everything else is justified custom code. Document the reasoning in the relevant header comment so the next sweep does not re-flag them. diff --git a/docs/refactor/datagrid-native-rewrite/09-dead-redundant.md b/docs/refactor/datagrid-native-rewrite/09-dead-redundant.md new file mode 100644 index 000000000..74e9d32d7 --- /dev/null +++ b/docs/refactor/datagrid-native-rewrite/09-dead-redundant.md @@ -0,0 +1,497 @@ +# 09 - Dead code, single-call-site abstractions, redundant logic + +Scope: `Views/Results/`, `Views/Results/Cells/`, `Views/Results/Extensions/`, +`Views/Editor/`, `Models/Query/`, `Core/Database/`, `Core/Plugins/`, +`Core/ChangeTracking/`. Reference counts collected with `grep -rn` across +`/Users/ngoquocdat/Projects/TablePro/TablePro` (worktree shadows excluded). +Symbols are flagged when their only caller is the declaration file or the +caller is itself dead. + +Legend in tables: refs = call sites outside the declaration file. Verdict: +**DELETE** = zero callers; **INLINE** = one caller, abstraction adds no +value; **KEEP** = real reuse; **REVIEW** = duplicated or cohabiting with +similar code. + +--- + +## A. Strong delete candidates (zero external callers) + +### A.1. `Views/Editor/QuerySuccessView.swift` - entire file + +`struct QuerySuccessView: View` exists only as a declaration plus its own +`#Preview`. Replaced by `Views/Results/ResultSuccessView`. The single +project-wide reference to "QuerySuccessView" is a comment in +`ResultSuccessView.swift:6` ("Replaces the full-screen QuerySuccessView for +multi-result contexts"). 0 callers. + +Action: delete `Views/Editor/QuerySuccessView.swift`. + +### A.2. `TableSelection.swift` - three methods + static + +| Symbol | refs | Verdict | +| --- | --- | --- | +| `TableSelection.empty` | 0 | DELETE | +| `TableSelection.hasFocus` | 0 | DELETE | +| `TableSelection.clearFocus()` | 0 | DELETE | +| `TableSelection.setFocus(row:column:)` | 0 | DELETE | +| `TableSelection.reloadIndexes(from:)` | 1 (`KeyHandlingTableView.swift:12`) | KEEP | + +`TableSelection` is only used by `KeyHandlingTableView.selection`. The four +items above were never wired up. After removing them the struct shrinks to +two stored properties and `reloadIndexes`. Once that is true, consider +folding the remaining bag into `KeyHandlingTableView` directly. + +### A.3. `DataGridView+RowActions.swift` - `setCellValue(_:at:)` + +``` +TablePro/Views/Results/DataGridView+RowActions.swift:79 + func setCellValue(_ value: String?, at rowIndex: Int) { … } +``` + +The only call to `setCellValue(_:at:)` is its own body, which forwards to +`setCellValueAtColumn`. No external caller. DELETE. + +`setCellValueAtColumn(_:at:columnIndex:)` is used by `TableRowViewWithMenu` +3 times - KEEP. + +### A.4. `TableRowViewWithMenu.undoInsertRow()` + +``` +TablePro/Views/Results/TableRowViewWithMenu.swift:218 + @objc private func undoInsertRow() { + coordinator?.undoInsertRow(at: rowIndex) + } +``` + +No `NSMenuItem` in the same file is ever wired with `#selector(undoInsertRow)` +(the menu only adds `undoDeleteRow`). The `@objc` is unreachable from the +context menu and unreachable through validation routing. DELETE. + +(Note: the *coordinator's* `undoInsertRow(at:)` is real and called from +`MainContentView.swift:384` and `MainContentCoordinator+RowOperations.swift`.) + +### A.5. `JSONEditorContentView` - thin wrapper + +``` +TablePro/Views/Results/JSONEditorContentView.swift // 50 lines, single caller +TablePro/Views/Results/Extensions/DataGridView+Popovers.swift:118 + JSONEditorContentView(initialValue: …, onCommit: …, onDismiss: …) +``` + +The view is a pass-through that constructs a binding around `text` and +delegates to `JSONViewerView`. It has one caller. INLINE its body into +`showJSONEditorPopover` (or move its 8 lines of compact-comparison logic into +`JSONViewerView` itself, see C.1). Delete the file afterward. + +--- + +## B. Single-call-site abstractions (INLINE candidates) + +These are not "dead" - they execute when called - but each has exactly one +caller, the abstraction is local to one feature, and the indirection adds +nothing. They were extracted because the host file is over the SwiftLint +warn threshold; the cure should be re-grouping by responsibility, not +shaving off one-liners. + +### B.1. `DataGridView+TypePicker.swift` - entire file (39 lines) + +| Symbol | refs | Verdict | +| --- | --- | --- | +| `showTypePickerPopover(...)` | 1 (`DataGridView+Click.swift:86`) | INLINE | + +One method, one caller. File should be deleted; the body either inlined +into `+Click` or moved into `+Popovers` next to its sibling popover +helpers. + +### B.2. `DataGridView+Popovers.swift` - most public methods are 1-call + +| Symbol | call site | Verdict | +| --- | --- | --- | +| `showDatePickerPopover` | `+Click.swift:104` | INLINE | +| `showForeignKeyPopover` | `+Click.swift:43` | INLINE | +| `showJSONEditorPopover` | `+Click.swift` (single) | INLINE | +| `showBlobEditorPopover` | `+Click.swift:108` | INLINE | +| `showEnumPopover` | `+Click.swift:100` | INLINE | +| `showSetPopover` | `+Click.swift:102` | INLINE | +| `showDropdownMenu` | `+Click.swift` and `+TypePicker` | KEEP (2 sites) | +| `toggleForeignKeyPreview` | `KeyHandlingTableView.swift:185` and `MainContentCoordinator+FKNavigation.swift` | KEEP | +| `showForeignKeyPreview` | `TableRowViewWithMenu.swift:298` and `+Click.swift` | KEEP | +| `commitPopoverEdit` | 5+ sites within the popover handlers | KEEP | +| `cellValue(at:column:)` | 3 sites | KEEP | +| `dropdownMenuItemSelected/Null` | NSMenu @objc selectors | KEEP | + +The whole file is essentially a single switch over click intent (already +expressed as a chain in `+Click.handleDoubleClick`). The split between +`+Click` (which decides what to show) and `+Popovers` (which shows it) is +near-tautological - `+Click` already knows the cell type. Six of the eight +public `show*Popover` methods exist purely to host "build NSPopover, set +content, present, register cleanup" boilerplate that is structurally +identical across all of them. + +Recommendation: collapse the popover construction into a single +`presentPopover(content:anchor:onCommit:)` helper plus inline call sites. +Keep `commitPopoverEdit` - it is the actual shared logic. + +### B.3. `DataGridView+CellPaste.swift` and `DataGridView+CellCommit.swift` + +Each file declares one method. + +| Symbol | refs | Verdict | +| --- | --- | --- | +| `pasteCellsFromClipboard(anchorRow:anchorColumn:)` | 1 (`KeyHandlingTableView.swift:114`) | INLINE/KEEP | +| `commitCellEdit(row:columnIndex:newValue:)` | 4 (used by `setCellValueAtColumn`, `commitOverlayEdit`, etc.) | KEEP | + +`commitCellEdit` is real shared infrastructure. `pasteCellsFromClipboard` has +one caller and 49 lines of local logic - could move into `KeyHandlingTableView` +where it is used. + +### B.4. `DataGridView+Selection.swift` + +All four NSTableViewDelegate callbacks (`tableViewColumnDidResize`, +`tableViewColumnDidMove`, `tableViewSelectionDidChange`) are AppKit-invoked +through the delegate protocol - they look 1-ref but the runtime is the +caller. KEEP. + +`scheduleLayoutPersist()` has 2 internal call sites (`tableViewColumnDidResize` +and selection change paths). KEEP. + +`resolvedFocus(...)` is private and used once inside the same extension. +Could be inlined; keeping it factored aids readability. KEEP. + +### B.5. `DataGridView+RowActions.swift` - what is used by what + +| Method | Callers | Verdict | +| --- | --- | --- | +| `addNewRow()` | `TableProApp.swift:446`, `MainContentCommandActions.swift:150` | KEEP | +| `undoDeleteRow(at:)` | `TableRowViewWithMenu.swift:215`, `MainContentView.swift` | KEEP | +| `undoInsertRow(at:)` | `MainContentView.swift:384`, `TableRowViewWithMenu.swift:219` (dead - see A.4) | KEEP | +| `copyRows(at:)` | `MainContentCommandActions.swift:215`, AppKit `copy:` selector | KEEP | +| `copyRowsWithHeaders` | `TableRowViewWithMenu.swift:227` | KEEP | +| `copyRowsAsInsert` | `TableRowViewWithMenu.swift:267` | KEEP | +| `copyRowsAsUpdate` | `TableRowViewWithMenu.swift:275` | KEEP | +| `copyRowsAsJson` | `TableRowViewWithMenu.swift:287` | KEEP | +| `setCellValue(_:at:)` | none | DELETE (A.3) | +| `setCellValueAtColumn` | `TableRowViewWithMenu.swift` (3) | KEEP | +| `copyCellValue(at:columnIndex:)` | `TableRowViewWithMenu.swift:244` | KEEP | +| `formatRowValues` | private, used twice | KEEP | +| `resolveDriver()` | private, 2 sites | KEEP | +| `tableView(_:pasteboardWriterForRow:)` etc. | NSTableViewDataSource conformance | KEEP | + +After deleting A.3, this file is justified by NSTableView drag-and-drop +plus copy variants. + +### B.6. `DataGridView+Sort.swift` - all `@objc` actions are NSMenuItem targets + +`sortAscending`, `sortDescending`, `clearSortAction`, `copyColumnName`, +`filterWithColumn`, `hideColumn`, `sizeColumnToFit`, `sizeAllColumnsToFit`, +`setDisplayFormat`, `showAllColumns` - each appears 2 times in code (declaration ++ `#selector(...)`), but all are reachable through NSMenu targets installed +in `menuNeedsUpdate(_:)`. KEEP. + +`tableView(_:sizeToFitWidthOfColumn:)` is an NSTableViewDelegate callback - +KEEP. + +### B.7. `DataGridView+Editing.swift` + +| Symbol | refs | Verdict | +| --- | --- | --- | +| `inlineEditEligibility` | 2 internal | KEEP | +| `canStartInlineEdit(row:columnIndex:)` | 1 (`KeyHandlingTableView.swift:92`) | KEEP (cross-file) | +| `tableView(_:shouldEdit:row:)` | NSTableViewDelegate | KEEP | +| `showOverlayEditor` | 3 sites (`KeyHandlingTableView`, `+Click`, recursion) | KEEP | +| `commitOverlayEdit` | 1 (closure capture) | KEEP | +| `handleOverlayTabNavigation` | 1 (closure capture) | KEEP | +| `control(_:textShouldEndEditing:)` | NSControlTextEditingDelegate | KEEP | +| `control(_:textView:doCommandBy:)` | NSControlTextEditingDelegate | KEEP | + +KEEP - file does cohesive work. + +### B.8. `DataGridView+Click.swift` - `handleChevronAction` / `handleFKArrowAction` + +Both have 1 caller each (`DataGridCoordinator.swift:597`, `:601`, in the +`DataGridCellAccessoryDelegate` conformance block). The delegate conformance +is one place but the file split has hidden the call. INLINE candidates if +the popover refactor in B.2 happens; otherwise KEEP - single cohesive flow. + +--- + +## C. Redundant / duplicated logic + +### C.1. JSON viewer triplet - `JSONViewerView` / `ResultsJsonView` / `JSONEditorContentView` / `JSONViewerWindowController` + +Four SwiftUI views/controllers with overlapping responsibilities. + +| File | Purpose | Distinctive | +| --- | --- | --- | +| `JSONViewerView.swift` (203 lines) | Reusable Text/Tree viewer with toolbar, edit footer, save-with-confirm dialog | The base view | +| `ResultsJsonView.swift` (171 lines) | Reusable Text/Tree viewer for query results, with row-count toolbar and Copy JSON button | Reads from `TableRows` instead of `Binding`; Copy button instead of edit footer | +| `JSONEditorContentView.swift` (50 lines) | Wraps `JSONViewerView` in a fixed-frame editor sheet for the cell-popover use case | Frames + compact-compare on commit | +| `JSONViewerWindowController.swift` (118 lines) | Detached window hosting `JSONViewerView` | Window lifecycle + same compact-compare on commit | + +Duplicated bodies across these files: + +1. **State trio + parse trigger.** `viewMode`, `treeSearchText`, `parsedTree`, + `parseError`, `prettyText` and `parseTree()` / `JSONTreeParser.parse(...)` + exist verbatim in both `JSONViewerView` and `ResultsJsonView`. +2. **`treeErrorView(_:)`.** Identical-shaped `ContentUnavailableView` block + in `JSONViewerView` (lines 116-131) and `ResultsJsonView` (lines 135-150), + differing only in the closing message string. +3. **Text/Tree segmented picker.** Same Picker code in both viewers. +4. **`onChange(of: viewMode)` writing back to `AppSettingsManager.shared.editor.jsonViewerPreferredMode`.** + Duplicated. +5. **Compact-compare on commit.** Identical 4-line block in + `JSONEditorContentView:39-43` and `JSONViewerWindowController:109-113`: + ``` + let normalizedNew = JSONViewerView.compact(newValue) + let normalizedOld = JSONViewerView.compact(initialValue) + if normalizedNew != normalizedOld { onCommit?(newValue) } + ``` + +Recommendation: +- Make `JSONViewerView` the canonical viewer with two modes: edit-binding + vs read-only string-view. +- Convert `ResultsJsonView` into a thin host that builds the JSON string + from `TableRows` and hands a read-only binding to `JSONViewerView` (or + inline the row-count toolbar around `JSONViewerView`). +- Delete `JSONEditorContentView.swift` (A.5). +- Move the compact-compare into `JSONViewerView.commitAndClose` so callers + don't reimplement it. + +Net: -200 to -250 lines, single source of truth for the JSON viewing +toolbar / picker / parse / error states. + +### C.2. `DataGridCellFactory` is misnamed + +`DataGridCellFactory` does not produce cells. It owns three column-width +calculators (`calculateColumnWidth`, `calculateOptimalColumnWidth`, +`calculateFitToContentWidth`) and nothing else. Cell production lives in +`DataGridCellRegistry.dequeueCell(of:in:)`. + +The `Cells/DataGridCellFactory.swift` filename + class name suggest overlap +with `DataGridCellRegistry`. There is none. KEEP the logic - REVIEW the +name. Suggested rename: `ColumnWidthCalculator` (or fold the three methods +into `DataGridColumnPool` since pooling and width sizing are co-managed - +the column pool already takes a `widthCalculator` closure parameter). + +The two non-trivial methods, `calculateOptimalColumnWidth` and +`calculateFitToContentWidth`, share ~14 lines of identical loop structure +differing only in (a) how `charCount` is bounded and (b) the early-return +behaviour when `maxColumnWidth` is hit. They could collapse to one +parameterised method with two thin entry points. + +### C.3. Cell registries are NOT redundant + +| Type | Responsibility | +| --- | --- | +| `DataGridCellFactory` | column **width** measurement (misnamed - see C.2) | +| `DataGridCellRegistry` | resolves `DataGridCellKind` and dequeues `DataGridBaseCellView` subclasses | +| `DataGridColumnPool` | pools `NSTableColumn` slots, applies layout, attaches headers | + +These three are orthogonal - KEEP. The original suspicion that they overlap +turns out to be naming-driven. + +### C.4. `TableViewCoordinating` protocol - single conformer, single user + +``` +Views/Results/TableViewCoordinating.swift:4 + protocol TableViewCoordinating: AnyObject { 9 methods } +Views/Results/TableViewCoordinating.swift:16 + extension TableViewCoordinator: TableViewCoordinating {} +``` + +One conformer (`TableViewCoordinator`). One declared user +(`DataTabGridDelegate.tableViewCoordinator: (any TableViewCoordinating)?`). + +Reading `DataTabGridDelegate.swift:114`, the `dataGridAttach` call +unconditionally assigns the concrete `TableViewCoordinator`. The protocol +exists only to weaken the property type on the delegate side; there is no +test double, no second implementation, no DI seam in use. + +Verdict: REVIEW. If no test/mock motivation appears, delete the protocol +and type the property as `weak var tableViewCoordinator: TableViewCoordinator?`. +That deletes a file (`TableViewCoordinating.swift`) and a layer of +indirection. + +### C.5. `AnyChangeManager` wrapping `ChangeManaging` + +``` +Core/ChangeTracking/AnyChangeManager.swift (69 lines) + protocol ChangeManaging + @Observable final class AnyChangeManager +``` + +Conformers of `ChangeManaging`: `DataChangeManager`, `StructureChangeManager`. + +Callers of `AnyChangeManager`: +- `DataGridView.swift:28` (the `var changeManager: AnyChangeManager`) +- `DataGridCoordinator.swift:13` +- `TableStructureView.swift:48`, `:64` (wraps `StructureChangeManager`) +- `CreateTableView.swift:37`, `:58` +- `MainEditorContentView.swift:71-77`, `:150` (wraps `DataChangeManager`) + +KEEP. Two distinct conformers and four distinct construction sites means +the wrapper is paying for itself. + +Minor: `MainEditorContentView` rebuilds the wrapper each time it falls +through the `cachedChangeManager` `nil` branch (line 77). Not dead code, +but a subtle correctness smell - the docstring says "Safe: onAppear fires +before any user interaction needs it" but a fresh instance per access +would defeat `@Observable` identity. Out of scope for this report; flag +to performance/threading owners. + +### C.6. `SortableHeaderCell` vs `SortableHeaderView` - NOT duplicated + +Investigated the suspicion that header drawing is split across both. They +divide cleanly: + +- `SortableHeaderCell` (`NSTableHeaderCell`) - drawing only: title, + ascending/descending indicator, multi-sort priority badge. +- `SortableHeaderView` (`NSTableHeaderView`) - input only: cursor rects, + click vs drag detection, multi-sort modifier, sort-cycle dispatch. + +`HeaderSortCycle.nextTransition` is the pure state-transition function +used once by `SortableHeaderView.mouseDown`. KEEP. + +### C.7. `HistoryDataProvider` + +``` +Views/Results/HistoryDataProvider.swift:40 final class HistoryDataProvider +Views/Editor/HistoryPanelView.swift:30 private let dataProvider = HistoryDataProvider() +``` + +One construction, one user. KEEP - it is a real `@Observable` data source +hosted in the editor's history panel. The location under +`Views/Results/` is misleading; the panel is rendered in the editor surface +and reads the `QueryHistory` persistence layer. REVIEW: move under +`Views/Editor/`. + +### C.8. `CellOverlayEditor` + +``` +Views/Results/CellOverlayEditor.swift // NSTextViewDelegate-driven editor +DataGridCoordinator.swift:93 var overlayEditor: CellOverlayEditor? +DataGridView+Editing.swift:80 overlayEditor = CellOverlayEditor() +``` + +Constructed once, retained on the coordinator, used by the multi-line +inline edit path. KEEP. + +### C.9. `TableRowViewWithMenu.menu(for:)` - used + +`TableRowViewWithMenu` is the row view returned from +`+Columns.tableView(_:rowViewForRow:)`. `KeyHandlingTableView.menu(for:)` +delegates to it (`KeyHandlingTableView.swift:342: return rowView.menu(for: event)`). +The menu hook is the entire reason for the subclass - KEEP. + +After deleting `undoInsertRow()` (A.4) the file shrinks but stays load-bearing. + +### C.10. `ResultSuccessView`, `InlineErrorBanner`, `HexEditorContentView`, +`DatePickerCellEditor`, `ForeignKeyPreviewView`, `KeyHandlingTableView` + +All wired up: + +| File | Caller | +| --- | --- | +| `ResultSuccessView` | `MainEditorContentView.swift:479` and `:490` | +| `InlineErrorBanner` | `MainEditorContentView.swift:468` | +| `HexEditorContentView` | `+Popovers.swift:151` | +| `DatePickerCellEditor` | `+Popovers.swift:177` | +| `ForeignKeyPreviewView` | `+Popovers.swift:88` | +| `KeyHandlingTableView` | `DataGridView.swift:51`; subclassed and used heavily | + +KEEP all. + +### C.11. `JSONBraceMatchingHelper` and `JSONHighlightPatterns` + +| Type | Used by | +| --- | --- | +| `JSONBraceMatchingHelper` | `JSONSyntaxTextView.swift:54`, `:154` | +| `JSONHighlightPatterns` | `JSONSyntaxTextView.swift:110, 112, 119, 120` | +| `JSONSyntaxTextView` | `JsonEditorView.swift:14`, `ResultsJsonView.swift:118`, `JSONViewerView.swift:100` | +| `JSONTreeView` | `JSONViewerView.swift:107`, `ResultsJsonView.swift:125` | + +All real users. KEEP. The duplication problem here is at the *viewer* layer +(C.1), not the helper layer. + +### C.12. `DataGridCellKind` - all branches reachable + +All seven cases (`text`, `foreignKey`, `dropdown`, `boolean`, `date`, +`json`, `blob`) appear in `DataGridCellRegistry.dequeueCell`'s switch. The +sole producer is `DataGridCellRegistry.resolveKind(...)` whose conditions +dispatch to all seven outcomes (FK / dropdown / boolean / date / json / +blob / text fallthrough). No unreachable branches. + +--- + +## D. DatabaseType switch reachability + +Searched `Core/Database/` for switches over `DatabaseType`. + +``` +DatabaseDriver.swift:465 switch connection.type { + case .mongodb: … + case .redis: … + case .mssql: … + case .oracle: … + default: break + } +``` + +Only switch in `Core/Database`. All four cases correspond to types with +plugin-bundled drivers in current code; the `default:` branch handles the +open-ended `DatabaseType` struct. No unreachable case. + +(Wider audit of switches across the app would require its own pass - out +of scope for this report.) + +--- + +## E. Empty / one-liner file candidates + +None of the swept files are empty. The smallest are: + +| File | Lines | Verdict | +| --- | --- | --- | +| `DataGridCellKind.swift` | 17 | KEEP - focused enum | +| `DataGridCellAccessoryDelegate.swift` | ~12 | KEEP - protocol contract | +| `TableViewCoordinating.swift` | 17 | DELETE if no test seam (C.4) | +| `DataGridView+TypePicker.swift` | 39 | DELETE (B.1) | +| `JSONEditorContentView.swift` | 50 | DELETE (A.5) | +| `DataGridView+CellPaste.swift` | 49 | INLINE candidate (B.3) | +| `DataGridView+CellCommit.swift` | 58 | KEEP | + +--- + +## F. Headline numbers + +- 1 entire file dead (`QuerySuccessView.swift`). +- 1 file dead pending the C.4 decision (`TableViewCoordinating.swift`). +- 2 files inline-or-delete candidates with one caller + (`DataGridView+TypePicker.swift`, `JSONEditorContentView.swift`). +- 6 zero-call-site members (`TableSelection.empty`, `hasFocus`, `clearFocus`, + `setFocus`, `DataGridView+RowActions.setCellValue(_:at:)`, + `TableRowViewWithMenu.undoInsertRow`). +- ~250 lines of duplicated JSON-viewer scaffolding across `JSONViewerView`, + `ResultsJsonView`, `JSONEditorContentView`, `JSONViewerWindowController` + (C.1). +- 1 misnamed type (`DataGridCellFactory` → ColumnWidthCalculator) with two + near-duplicate width methods that can collapse to one parameterised + method (C.2). +- 6 of 8 `show*Popover` methods in `DataGridView+Popovers.swift` have a + single caller in `DataGridView+Click.swift`; the file is mostly + boilerplate around `NSPopover` setup (B.2). + +## G. Out-of-scope notes + +- `Core/Plugins/` and most of `Models/Query/` were swept; one external + reference outlier (`LinkedFavoriteTransfer`, 1 caller) is a real + `Transferable` drag payload - KEEP. +- `QueryTabState` shows 0 external refs because every type in that file is + imported under its own name (`SortState`, `PaginationState`, etc.); not + dead. +- `LineCutCalculator` (single caller) is small, pure, and tested in + isolation. KEEP. + +--- + +End of inventory.