Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions OpenTable/Models/QueryTab.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down Expand Up @@ -281,6 +284,7 @@ struct QueryTab: Identifiable, Equatable {
self.hasUserInteraction = false
self.pagination = PaginationState()
self.filterState = TabFilterState()
self.resultVersion = 0
self.tableCreationOptions = nil
}

Expand Down Expand Up @@ -311,6 +315,7 @@ struct QueryTab: Identifiable, Equatable {
self.hasUserInteraction = false
self.pagination = PaginationState()
self.filterState = TabFilterState()
self.resultVersion = 0
self.tableCreationOptions = nil
}

Expand Down Expand Up @@ -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
Expand Down
78 changes: 46 additions & 32 deletions OpenTable/Models/RowProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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..<endIndex])
var result: [TableRowData] = []
result.reserveCapacity(endIndex - offset)
for i in offset..<endIndex {
result.append(materializeRow(at: i))
}
return result
}

func prefetchRows(at indices: [Int]) {
// No-op for in-memory provider - all data already loaded
// No-op for in-memory provider - all data already available
}

func invalidateCache() {
// No-op for in-memory provider
rowCache.removeAll()
}

/// Update a cell value
func updateValue(_ value: String?, at rowIndex: Int, columnIndex: Int) {
guard rowIndex < rows.count else { return }
rows[rowIndex].setValue(value, at: columnIndex)
guard rowIndex < sourceRows.count else { return }
// Update the source row
sourceRows[rowIndex].values[columnIndex] = value
// Update cached TableRowData if it exists
rowCache[rowIndex]?.setValue(value, at: columnIndex)
}

/// Get row data at index
func row(at index: Int) -> 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..<rows.count {
rows[i] = TableRowData(index: i, values: rows[i].values)
}
guard 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<Int>) {
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..<rows.count {
rows[i] = TableRowData(index: i, values: rows[i].values)
// Clear entire cache since indices shift
rowCache.removeAll()
}

// MARK: - Private

private func materializeRow(at index: Int) -> TableRowData {
if let cached = rowCache[index] {
return cached
}
let rowData = TableRowData(index: index, values: sourceRows[index].values)
rowCache[index] = rowData
return rowData
}
}

Expand Down
37 changes: 36 additions & 1 deletion OpenTable/Views/Main/Child/MainEditorContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Comment thread
datlechin marked this conversation as resolved.
}

let sorted = tab.resultRows.sorted { row1, row2 in
let val1 = row1.values[columnIndex] ?? ""
let val2 = row2.values[columnIndex] ?? ""

Expand All @@ -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<SortState> {
Expand Down
10 changes: 6 additions & 4 deletions OpenTable/Views/Main/Child/MainStatusBarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions OpenTable/Views/Main/MainContentCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down