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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Data grids with hundreds of columns no longer format every offscreen cell while warming the scroll cache. Cache work now follows the columns in the viewport, which keeps wide ClickHouse results responsive while scrolling. (#1219)
- The license activation sheet now opens when you click **Activate License**. It was built and then failed to appear, and once that happened further clicks did nothing at all.
- File > Import from Other App..., Open Project Folder..., Import Connections... and Export Connections... now work when no welcome window is open. They used to do nothing.
- The tooltip on the welcome screen's **+** button shows the shortcut you actually have bound for New Connection instead of always claiming ⌘N.
Expand Down
80 changes: 62 additions & 18 deletions TablePro/Core/DataGrid/RowDisplayBox.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,68 @@
import Foundation

final class RowDisplayBox {
var values: ContiguousArray<String?>
private enum CachedValue {
case text(String)
case empty
}

private var valuesByColumn: [Int: CachedValue] = [:]
private(set) var cost: Int = 0

init(_ values: ContiguousArray<String?> = []) {
valuesByColumn.reserveCapacity(values.count)
for (column, value) in values.enumerated() {
setValue(value, at: column)
}
}

var cachedColumnCount: Int {
valuesByColumn.count
}

func containsValue(at column: Int) -> Bool {
valuesByColumn[column] != nil
}

init(_ values: ContiguousArray<String?>) {
self.values = values
func value(at column: Int) -> String? {
guard let value = valuesByColumn[column] else { return nil }
switch value {
case .text(let text):
return text
case .empty:
return nil
}
}

func setValue(_ value: String?, at column: Int) {
guard column >= 0 else { return }
if case .text(let previous)? = valuesByColumn[column] {
cost -= previous.utf8.count
}
if let value {
valuesByColumn[column] = .text(value)
cost += value.utf8.count
} else {
valuesByColumn[column] = .empty
}
}

func invalidateValue(at column: Int) {
guard let previous = valuesByColumn.removeValue(forKey: column) else { return }
if case .text(let previous) = previous {
cost -= previous.utf8.count
}
}
}

@MainActor
final class RowDisplayCache {
private var storage: [RowID: RowDisplayBox] = [:]
private struct Entry {
let box: RowDisplayBox
let cost: Int
}

private var storage: [RowID: Entry] = [:]
private var insertionOrder: [RowID] = []
private var insertionHead: Int = 0
private var totalCost: Int = 0
Expand All @@ -28,17 +80,17 @@ final class RowDisplayCache {
}

func box(forID id: RowID) -> RowDisplayBox? {
storage[id]
storage[id]?.box
}

func setBox(_ box: RowDisplayBox, forID id: RowID, cost: Int) {
func setBox(_ box: RowDisplayBox, forID id: RowID) {
if let existing = storage[id] {
totalCost -= rowCost(existing.values)
totalCost -= existing.cost
} else {
insertionOrder.append(id)
}
storage[id] = box
totalCost += cost
storage[id] = Entry(box: box, cost: box.cost)
totalCost += box.cost
evictIfNeeded()
}

Expand All @@ -55,20 +107,12 @@ final class RowDisplayCache {
let oldest = insertionOrder[insertionHead]
insertionHead += 1
if let removed = storage.removeValue(forKey: oldest) {
totalCost -= rowCost(removed.values)
totalCost -= removed.cost
}
}
if insertionHead > 10_000 {
insertionOrder.removeFirst(insertionHead)
insertionHead = 0
}
}

private func rowCost(_ values: ContiguousArray<String?>) -> Int {
var total = 0
for value in values {
if let s = value { total &+= s.utf8.count }
}
return total
}
}
106 changes: 61 additions & 45 deletions TablePro/Views/Results/DataGridCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -376,31 +376,15 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData

func displayValue(forID id: RowID, column: Int, rawValue: PluginCellValue, columnType: ColumnType?) -> String? {
if let box = displayCache.box(forID: id),
column >= 0, column < box.values.count,
let cached = box.values[column] {
return cached
box.containsValue(at: column) {
return box.value(at: column)
}
let format = column >= 0 && column < columnDisplayFormats.count ? columnDisplayFormats[column] : nil
let formatted = CellDisplayFormatter.format(rawValue, columnType: columnType, displayFormat: format) ?? rawValue.asText

let neededCount = max(column + 1, columnDisplayFormats.count, cachedColumnCount)
let box: RowDisplayBox
if let existing = displayCache.box(forID: id) {
box = existing
if box.values.count < neededCount {
box.values.reserveCapacity(neededCount)
for _ in box.values.count..<neededCount { box.values.append(nil) }
}
} else {
var values = ContiguousArray<String?>()
values.reserveCapacity(neededCount)
for _ in 0..<neededCount { values.append(nil) }
box = RowDisplayBox(values)
}
if column >= 0, column < box.values.count {
box.values[column] = formatted
}
displayCache.setBox(box, forID: id, cost: displayCacheCost(box.values))
let box = displayCache.box(forID: id) ?? RowDisplayBox()
box.setValue(formatted, at: column)
displayCache.setBox(box, forID: id)
return formatted
}

Expand All @@ -425,12 +409,15 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData
}

func preWarmDisplayCache(upTo rowCount: Int) {
guard let tableView else { return }
let columnIndices = visibleDataColumnIndices(in: tableView)
guard !columnIndices.isEmpty else { return }
let tableRows = tableRowsProvider()
let displayCount = displayIDs?.count ?? tableRows.count
let count = min(rowCount, displayCount)
guard count > 0 else { return }
for displayIndex in 0..<count {
cacheDisplayRow(at: displayIndex, in: tableRows)
cacheDisplayRow(at: displayIndex, columnIndices: columnIndices, in: tableRows)
}
}

Expand Down Expand Up @@ -492,6 +479,9 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData
}

private func runBackgroundPrewarm() async {
guard let tableView else { return }
let columnIndices = visibleDataColumnIndices(in: tableView)
guard !columnIndices.isEmpty else { return }
var nextIndex = 0
while !Task.isCancelled {
let tableRows = tableRowsProvider()
Expand All @@ -501,49 +491,75 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData
let deadline = ContinuousClock.now.advanced(by: Self.prewarmFrameBudget)
while nextIndex < displayCount {
if Task.isCancelled { return }
cacheDisplayRow(at: nextIndex, in: tableRows)
cacheDisplayRow(at: nextIndex, columnIndices: columnIndices, in: tableRows)
nextIndex += 1
if ContinuousClock.now >= deadline { break }
}
await Task.yield()
}
}

private func cacheDisplayRow(at displayIndex: Int, in tableRows: TableRows) {
guard let row = displayRow(at: displayIndex, in: tableRows) else { return }
guard displayCache.box(forID: row.id) == nil else { return }

let columnCount = tableRows.columns.count
var values = ContiguousArray<String?>()
values.reserveCapacity(columnCount)
for _ in 0..<columnCount { values.append(nil) }
for col in 0..<min(row.values.count, columnCount) {
@discardableResult
func cacheDisplayRow(
at displayIndex: Int,
columnIndices: IndexSet,
in tableRows: TableRows
) -> Int {
guard let row = displayRow(at: displayIndex, in: tableRows) else { return 0 }
let box = displayCache.box(forID: row.id) ?? RowDisplayBox()
let columnCount = min(row.values.count, tableRows.columns.count)
var cachedCount = 0
for col in columnIndices where col >= 0 && col < columnCount && !box.containsValue(at: col) {
let columnType = col < tableRows.columnTypes.count ? tableRows.columnTypes[col] : nil
let format = col < columnDisplayFormats.count ? columnDisplayFormats[col] : nil
values[col] = CellDisplayFormatter.format(
let formatted = CellDisplayFormatter.format(
row.values[col],
columnType: columnType,
displayFormat: format
) ?? row.values[col].asText
box.setValue(formatted, at: col)
cachedCount += 1
}
let box = RowDisplayBox(values)
displayCache.setBox(box, forID: row.id, cost: displayCacheCost(values))
}

private func displayCacheCost(_ values: ContiguousArray<String?>) -> Int {
var total = 0
for value in values {
if let s = value { total &+= s.utf8.count }
guard cachedCount > 0 else { return 0 }
displayCache.setBox(box, forID: row.id)
return cachedCount
}

func visibleDataColumnIndices(in tableView: NSTableView) -> IndexSet {
let visibleRect = tableView.visibleRect
let tableMaxY = tableView.bounds.maxY
guard visibleRect.width > 0, tableMaxY > 0,
let firstContentColumn = tableView.tableColumns.firstIndex(where: { !$0.isHidden }),
let lastContentColumn = tableView.tableColumns.lastIndex(where: { !$0.isHidden }) else {
return IndexSet()
}
let contentMinX = tableView.rect(ofColumn: firstContentColumn).minX
let contentMaxX = tableView.rect(ofColumn: lastContentColumn).maxX
guard contentMaxX > contentMinX,
visibleRect.maxX > contentMinX,
visibleRect.minX < contentMaxX else { return IndexSet() }
let probeY = min(max(visibleRect.midY, tableView.bounds.minY), tableMaxY - 1)
let leadingX = min(max(visibleRect.minX, contentMinX), contentMaxX - 1)
let trailingX = min(max(visibleRect.maxX - 1, leadingX), contentMaxX - 1)
let firstVisibleColumn = tableView.column(at: NSPoint(x: leadingX, y: probeY))
let lastVisibleColumn = tableView.column(at: NSPoint(x: trailingX, y: probeY))
guard firstVisibleColumn >= 0, lastVisibleColumn >= firstVisibleColumn else { return IndexSet() }
var indices = IndexSet()
for tableColumnIndex in firstVisibleColumn...lastVisibleColumn {
let tableColumn = tableView.tableColumns[tableColumnIndex]
guard !tableColumn.isHidden,
let dataColumnIndex = dataColumnIndex(from: tableColumn.identifier) else { continue }
indices.insert(dataColumnIndex)
}
return total
return indices
}

private func invalidateDisplayCache(forDisplayRow displayIndex: Int, column: Int) {
guard let row = displayRow(at: displayIndex) else { return }
guard let box = displayCache.box(forID: row.id),
column >= 0, column < box.values.count else { return }
box.values[column] = nil
displayCache.setBox(box, forID: row.id, cost: displayCacheCost(box.values))
box.containsValue(at: column) else { return }
box.invalidateValue(at: column)
displayCache.setBox(box, forID: row.id)
}

func applyDelta(_ delta: Delta) {
Expand Down
Loading
Loading