diff --git a/OpenTable/Models/QueryTab.swift b/OpenTable/Models/QueryTab.swift index 750e34917..c32495a60 100644 --- a/OpenTable/Models/QueryTab.swift +++ b/OpenTable/Models/QueryTab.swift @@ -247,6 +247,9 @@ struct QueryTab: Identifiable, Equatable { // Per-tab filter state (preserves filters when switching tabs) var filterState: TabFilterState + // Version counter incremented when resultRows changes (used for sort caching) + var resultVersion: Int + // Table creation options (for .createTable tabs only) var tableCreationOptions: TableCreationOptions? @@ -281,6 +284,7 @@ struct QueryTab: Identifiable, Equatable { self.hasUserInteraction = false self.pagination = PaginationState() self.filterState = TabFilterState() + self.resultVersion = 0 self.tableCreationOptions = nil } @@ -311,6 +315,7 @@ struct QueryTab: Identifiable, Equatable { self.hasUserInteraction = false self.pagination = PaginationState() self.filterState = TabFilterState() + self.resultVersion = 0 self.tableCreationOptions = nil } @@ -455,6 +460,7 @@ final class QueryTabManager: ObservableObject { tabs[selectedIndex].query = "SELECT * FROM \(quotedName) LIMIT \(pageSize);" tabs[selectedIndex].resultColumns = [] tabs[selectedIndex].resultRows = [] + tabs[selectedIndex].resultVersion += 1 tabs[selectedIndex].executionTime = nil tabs[selectedIndex].errorMessage = nil tabs[selectedIndex].lastExecutedAt = nil diff --git a/OpenTable/Models/RowProvider.swift b/OpenTable/Models/RowProvider.swift index 465624373..73d312d5f 100644 --- a/OpenTable/Models/RowProvider.swift +++ b/OpenTable/Models/RowProvider.swift @@ -57,90 +57,104 @@ final class TableRowData { // MARK: - In-Memory Row Provider -/// Row provider that keeps all data in memory (for existing QueryResultRow data) +/// Row provider that keeps all data in memory (for existing QueryResultRow data). +/// Uses lazy TableRowData creation to avoid O(n) heap allocations on init. final class InMemoryRowProvider: RowProvider { - private var rows: [TableRowData] = [] + private var sourceRows: [QueryResultRow] + private var rowCache: [Int: TableRowData] = [:] private(set) var columns: [String] private(set) var columnDefaults: [String: String?] private(set) var columnTypes: [ColumnType] var totalRowCount: Int { - rows.count + sourceRows.count } init(rows: [QueryResultRow], columns: [String], columnDefaults: [String: String?] = [:], columnTypes: [ColumnType]? = nil) { self.columns = columns self.columnDefaults = columnDefaults - // Default to .text if columnTypes not provided self.columnTypes = columnTypes ?? Array(repeating: ColumnType.text(rawType: nil), count: columns.count) - self.rows = rows.enumerated().map { index, row in - TableRowData(index: index, values: row.values) - } + self.sourceRows = rows } func fetchRows(offset: Int, limit: Int) -> [TableRowData] { - let endIndex = min(offset + limit, rows.count) + let endIndex = min(offset + limit, sourceRows.count) guard offset < endIndex else { return [] } - return Array(rows[offset.. TableRowData? { - guard index >= 0 && index < rows.count else { return nil } - return rows[index] + guard index >= 0 && index < sourceRows.count else { return nil } + return materializeRow(at: index) } /// Update rows from QueryResultRow array func updateRows(_ newRows: [QueryResultRow]) { - self.rows = newRows.enumerated().map { index, row in - TableRowData(index: index, values: row.values) - } + self.sourceRows = newRows + self.rowCache.removeAll() } /// Append a new row with given values /// Returns the index of the new row func appendRow(values: [String?]) -> Int { - let newIndex = rows.count - let newRow = TableRowData(index: newIndex, values: values) - rows.append(newRow) + let newIndex = sourceRows.count + sourceRows.append(QueryResultRow(values: values)) + let rowData = TableRowData(index: newIndex, values: values) + rowCache[newIndex] = rowData return newIndex } /// Remove row at index (used when discarding new rows) func removeRow(at index: Int) { - guard index >= 0 && index < rows.count else { return } - rows.remove(at: index) - // Re-index remaining rows - for i in index..= 0 && index < sourceRows.count else { return } + sourceRows.remove(at: index) + // Clear entire cache since indices shift + rowCache.removeAll() } /// Remove multiple rows at indices (used when discarding new rows) /// Indices should be sorted in descending order to maintain correct removal func removeRows(at indices: Set) { for index in indices.sorted(by: >) { - guard index >= 0 && index < rows.count else { continue } - rows.remove(at: index) + guard index >= 0 && index < sourceRows.count else { continue } + sourceRows.remove(at: index) } - // Re-index all remaining rows - for i in 0.. TableRowData { + if let cached = rowCache[index] { + return cached } + let rowData = TableRowData(index: index, values: sourceRows[index].values) + rowCache[index] = rowData + return rowData } } diff --git a/OpenTable/Views/Main/Child/MainEditorContentView.swift b/OpenTable/Views/Main/Child/MainEditorContentView.swift index 6602f6d46..1e0be6002 100644 --- a/OpenTable/Views/Main/Child/MainEditorContentView.swift +++ b/OpenTable/Views/Main/Child/MainEditorContentView.swift @@ -8,6 +8,14 @@ import SwiftUI +/// Cache for sorted query result rows to avoid re-sorting on every SwiftUI body evaluation +private struct SortedRowsCache { + let rows: [QueryResultRow] + let columnIndex: Int + let direction: SortDirection + let resultVersion: Int +} + /// Main editor content with tab bar and content switching struct MainEditorContentView: View { // MARK: - Dependencies @@ -45,6 +53,10 @@ struct MainEditorContentView: View { let onOffsetChange: (Int) -> Void let onPaginationGo: () -> Void + // MARK: - Sort Cache + + @State private var sortCache: [UUID: SortedRowsCache] = [:] + // MARK: - Environment @EnvironmentObject private var appState: AppState @@ -75,6 +87,11 @@ struct MainEditorContentView: View { } } .animation(.easeInOut(duration: 0.2), value: appState.isHistoryPanelVisible) + .onChange(of: tabManager.tabs.count) { _ in + // Clean up sort cache for closed tabs + let openTabIds = Set(tabManager.tabs.map(\.id)) + sortCache = sortCache.filter { openTabIds.contains($0.key) } + } } // MARK: - Tab Content @@ -252,7 +269,15 @@ struct MainEditorContentView: View { return tab.resultRows } - return tab.resultRows.sorted { row1, row2 in + // Check sort cache to avoid re-sorting on every render + if let cached = sortCache[tab.id], + cached.columnIndex == columnIndex, + cached.direction == tab.sortState.direction, + cached.resultVersion == tab.resultVersion { + return cached.rows + } + + let sorted = tab.resultRows.sorted { row1, row2 in let val1 = row1.values[columnIndex] ?? "" let val2 = row2.values[columnIndex] ?? "" @@ -262,6 +287,16 @@ struct MainEditorContentView: View { return val1.localizedStandardCompare(val2) == .orderedDescending } } + + // Cache the result + sortCache[tab.id] = SortedRowsCache( + rows: sorted, + columnIndex: columnIndex, + direction: tab.sortState.direction, + resultVersion: tab.resultVersion + ) + + return sorted } private func sortStateBinding(for tab: QueryTab) -> Binding { diff --git a/OpenTable/Views/Main/Child/MainStatusBarView.swift b/OpenTable/Views/Main/Child/MainStatusBarView.swift index e4439455d..8e119bea6 100644 --- a/OpenTable/Views/Main/Child/MainStatusBarView.swift +++ b/OpenTable/Views/Main/Child/MainStatusBarView.swift @@ -109,16 +109,18 @@ struct MainStatusBarView: View { } else { return "\(selectedCount) of \(loadedCount) rows selected" } - } else if let total = total, total > 0 { - // Pagination mode: "201-400 of 5,000 rows" + } else if tab.tabType == .table, let total = total, total > 0 { + // Pagination mode (table tabs only): "201-400 of 5,000 rows" let formatter = NumberFormatter() formatter.numberStyle = .decimal let formattedTotal = formatter.string(from: NSNumber(value: total)) ?? "\(total)" return "\(pagination.rangeStart)-\(pagination.rangeEnd) of \(formattedTotal) rows" } else if loadedCount > 0 { - // Simple mode: "100 rows" - return "\(loadedCount) rows" + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + let formattedCount = formatter.string(from: NSNumber(value: loadedCount)) ?? "\(loadedCount)" + return "\(formattedCount) rows" } else { return "No rows" } diff --git a/OpenTable/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift b/OpenTable/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift index bbda7b539..30beac9a7 100644 --- a/OpenTable/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift +++ b/OpenTable/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift @@ -128,6 +128,7 @@ extension MainContentCoordinator { ) tabManager.tabs[index].resultRows = tab.resultRows + tabManager.tabs[index].resultVersion += 1 // Select pasted rows and scroll to first one if !pastedRows.isEmpty { diff --git a/OpenTable/Views/Main/MainContentCoordinator.swift b/OpenTable/Views/Main/MainContentCoordinator.swift index e89cf6fe5..bade0a55c 100644 --- a/OpenTable/Views/Main/MainContentCoordinator.swift +++ b/OpenTable/Views/Main/MainContentCoordinator.swift @@ -266,6 +266,7 @@ final class MainContentCoordinator: ObservableObject { updatedTab.columnTypes = safeColumnTypes updatedTab.columnDefaults = safeColumnDefaults updatedTab.resultRows = safeRows + updatedTab.resultVersion += 1 updatedTab.executionTime = safeExecutionTime updatedTab.rowsAffected = result.rowsAffected updatedTab.isExecuting = false @@ -1183,6 +1184,7 @@ final class MainContentCoordinator: ObservableObject { var updatedTab = tab updatedTab.resultColumns = [] updatedTab.resultRows = [] + updatedTab.resultVersion += 1 updatedTab.errorMessage = nil updatedTab.executionTime = nil return updatedTab