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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- Flickering columns, blank columns, and an unpainted gap while scrolling a result with about 100 columns sideways. (#2381)
- Find, arrow keys, and the inline editor unable to reach a column scrolled off the side of a wide result.
- Return opening no editor on a row selected with the arrow keys.
- Tab out of a row's last cell and Shift+Tab out of its first doing nothing.
- Size All Columns to Fit leaving the far columns of a wide result unreachable.

## [0.67.1] - 2026-08-22

### Added
Expand Down
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,10 @@ To ship one: add the record type or field in CloudKit Console (or `xcrun cktool

**The data grid header owns all of its own chrome, so nothing may ask AppKit to paint any of it**: `NSTableHeaderCell` and `NSTableHeaderView` both paint a fixed 28pt band that they centre vertically in whatever frame they are given, a 16pt column divider on `midY` and a 1pt rule at `midY + 13`. The data grid grows its header to 42pt for a column comment, so that band lands mid-cell: the rule crosses the comment's descenders and sits 8pt above the real bottom edge. `SortableHeaderChrome` is therefore the single owner of header geometry and colours, `SortableHeaderCell.draw(withFrame:in:)` never calls `super`, and `SortableHeaderView.draw(_:)` fills the background and rules the bottom edge itself. The trap is that the header view paints a second copy of that same band for `NSTableView.highlightedTableColumn`, driven by *state* rather than by a drawing call, so no cell override can reach it: setting it gives the sorted column a stray divider and a rule no other column has. TablePro already draws the sorted-column affordance itself (bold title, chevron, priority number, with `drawSortIndicator` overridden to nothing), so `highlightedTableColumn` is a redundant second channel and must stay unset. All sorted-column presentation goes through `SortableHeaderView.applySortState(_:schema:)`, which publishes the order natively through `tableView.sortDescriptors` (for accessibility; it paints nothing) and updates the cells. `SortableHeaderRenderingTests` rasterises the header and guards this. This shipped as a rule through the comment line and a stray divider on the sorted column (#2017).

**The data grid's column window measures the viewport against the whole column run, so its rebase counts chrome only**: `ColumnWindowResolver` is pure and models every column from the first data column onward, and `DataGridColumnPool.applyColumnWindow` has to move the live viewport into that space before it can pick a range. Only chrome may be subtracted there. The leading spacer sits ahead of the first data column and holds exactly the width of the columns the window left out, which the resolver's model already carries, so counting it subtracts that width twice: the window walks left while the reader scrolls right, alternates between two ranges, and finally parks off screen, where the grid paints nothing until the table is reopened. That shipped as #2381, and a frame-by-frame capture of one scroll sweep across a 100-column table shows it plainly: the worst frame left 100% of the grid unpainted before the fix and 40pt, one column gutter, after it. Two measured AppKit facts hold the design up. A visible column occupies exactly `width + intercellSpacing.width` and a hidden one occupies nothing, spacing included, which is what lets the spacers keep the document width and therefore the scroll extent. And `rect(ofColumn:)` and `frameOfCell(atColumn:row:)` are both empty for a hidden column, so anything that reads a column's frame has to mount it first through `TableViewCoordinator.scrollColumnToVisible(tableColumnIndex:)`, which re-centres a bounded window instead of stretching the mounted range out to the target, because stretching mounts every column in between (848ms and 3,081 cell views for a match 90 columns away, 4.8s at 500). A test here measures through `rect(ofColumn:)` and never through the resolver, or it only proves the resolver agrees with itself.

**No fixed position in `tableColumns` names a data column**: the attached order is `[__rowNumber__, __leadingSpacer__, data columns, surplus pool slots, __trailingSpacer__]`, so `presentsColumn` is the question to ask, with `firstPresentedColumnIndex` and its neighbours beside it on `DataGridColumnPool`. `DataGridView.firstDataTableColumnIndex` was a hardcoded `1` that the leading spacer took over when windowing landed, and `isDataTableColumn` accepted the trailing spacer at the other end. The cell cursor was seeded onto a spacer whenever the selection moved without a click, so Down then Return did nothing on any table while the Edit menu item still validated as enabled, Tab out of a row's last cell and Shift+Tab out of its first were swallowed, and `scrollColumnToVisible` on a column the window had unmounted scrolled to the document origin instead of the column (#2381).

**Decoding a MongoDB binary UUID is a per-column decision, and the column's type name is load-bearing**: BSON binary subtype 3 is the legacy UUID format, and the Java, C# and Python drivers each wrote it with a different byte order with nothing in the stored bytes to say which. `MongoDBUuidCodec` therefore decodes subtype 3 only when the connection names one (`mongoUuidRepresentation`); subtype 4 is unambiguous and always decodes. The choice is made once per column from `BsonDocumentFlattener.columnKinds`' majority vote, never per value, because a decoded cell is `.text` and an undecoded one is `.bytes`, and `CellDisplayFormatter` runs blob formatting over a `.text` cell whenever its column type is BLOB. One UUID decoded inside a column the app still types `BLOB` renders as `0x4c65676163...`. For the same reason `BsonDocumentFlattener.typeName` must keep `BLOB` as the base name for undecoded binary: `ColumnTypeClassifier` splits a type name at the first `(` and looks the base up, so `BLOB` and `BLOB(3)` both classify as `.blob`, and that classification is the only thing keeping a binary cell out of the inline editor. The parenthesised part carries the BSON subtype so MQL export can write it back; `MongoDBUuidCodec.columnTypeName(forSubtype:)` and `binarySubtype(fromColumnTypeName:)` are the only two places that spelling is produced or read, and MQL export is `supportedDatabaseTypeIds = ["MongoDB"]`, so it never sees another driver's `BLOB`. Once a column does decode, both edit guards (`isBlobType` and `asBytes != nil`) fall together, so every write path must parse the wrapper back to `$binary`: `MongoDBStatementGenerator.jsonValue` and `idValueJson`, `MongoDBQueryBuilder.jsonValue` plus its `=`, `!=` and `IN` arms (a case-insensitive regex can never match a binary field), and `MQLExportHelpers.mqlJsonValue`. An `_id` filter left as wrapper text matches zero documents while the UI reports the save succeeded. (#2086)

**A pooled metadata read assumes a second connection reaches the same database, and an embedded engine breaks that assumption**: `MetadataConnectionPool` builds a whole new driver, so it is only correct when the database lives on a server the driver reconnects to. When the database lives *inside* the driver instance, the pool gets a different database: a second `duckdb_open(":memory:")` is a fresh empty database, and a second `duckdb_open` on the same *file* is a second independent read-write instance that the first never sees (DuckDB's file lock does not conflict within one process). The failure is silent, because an empty catalog is indistinguishable from "no tables", which is why #2108 survived a manual refresh. `supportsConnectionPooling` is the opt-out, and it is read only by `DatabaseManager.canPool`; DuckDB and PGlite set it `false`. SQLite-family engines keep pooling, because multi-connection access to one file is what they are built for. Two rules follow. First, every metadata read goes through `DatabaseManager.withMetadataDriver` so `metadataRoute` can apply the rule; reaching for `MetadataConnectionPool.shared.withDriver` directly bypasses it, which is how routines kept pooling after the sidebar stopped. Second, a capability with no `DriverPlugin` static is curated per type and `buildMetadataSnapshot` must carry it over from the built-in snapshot, or `register(snapshot:forTypeId:)` resets it to the struct default the moment the plugin loads. That is not hypothetical: it silently disabled MongoDB's `authenticationIsDatabaseScoped` (#1970) for every build that had the plugin installed. `registerVariant` already treats the curated entry as authoritative, which is the only reason PGlite's flag ever worked.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ extension MainContentCoordinator {
guard let tableView = NSApp.keyWindow?.firstResponder as? KeyHandlingTableView,
let coordinator = tableView.coordinator,
tableView.selectedRow >= 0,
DataGridView.isDataTableColumn(tableView.focusedColumn),
tableView.presentsDataColumn(at: tableView.focusedColumn),
let columnIndex = DataGridView.dataColumnIndex(
for: tableView.focusedColumn,
in: tableView,
Expand Down
20 changes: 20 additions & 0 deletions TablePro/Views/Results/ColumnWindowResolver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,26 @@ internal enum ColumnWindowResolver {
return window(for: desired, columnWidths: columnWidths)
}

/// A window over `index`, for a caller that needs a column's frame before the viewport has
/// reached it.
///
/// Re-centres rather than stretching the mounted range out to reach the target. Spanning from
/// the current range to a far column mounts every column in between, which is the whole cost
/// the window exists to avoid: measured at 848ms and 3,081 cell views for one Find match 90
/// columns away, and 4.8s at 500 columns. The caller scrolls in the same turn, so the columns
/// this drops were never drawn again anyway.
///
/// - Returns: `nil` when the range already covers `index` and there is nothing to mount.
internal static func window(
containing index: Int,
columnWidths: [CGFloat],
current: Range<Int>?
) -> Window? {
guard columnWidths.indices.contains(index) else { return nil }
if let current, current.contains(index) { return nil }
return window(for: padded(index..<(index + 1), count: columnWidths.count), columnWidths: columnWidths)
}

/// The columns the viewport actually intersects. Always at least one column, so a viewport
/// narrower than a single column still mounts the one under it.
private static func visibleRange(
Expand Down
92 changes: 80 additions & 12 deletions TablePro/Views/Results/DataGridColumnPool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -154,9 +154,7 @@ final class DataGridColumnPool {
/// Hiding the far ones is measurably close to free; the spacers keep the document width and
/// therefore the scroll extent identical to mounting everything.
func applyColumnWindow(in tableView: NSTableView) {
let candidates = tableView.tableColumns.filter {
activeIdentifiers.contains($0.identifier) && !userHiddenIdentifiers.contains($0.identifier)
}
let candidates = presentedColumns(in: tableView)
guard !candidates.isEmpty else {
hideSpacers()
return
Expand All @@ -168,36 +166,106 @@ final class DataGridColumnPool {
return
}

// The document starts at the row-number column, but the resolver measures from the first
// data column, so the viewport has to be rebased before it can index into these widths.
let leadingChrome = tableView.tableColumns
.prefix { $0.identifier != candidates[0].identifier }
.filter { !$0.isHidden }
.reduce(0) { $0 + $1.width + tableView.intercellSpacing.width }

// A mounted column contributes its width plus one intercell gap; a hidden one contributes
// nothing. A spacer standing in for N columns has to carry their gaps too, or the document
// ends up short and the last columns cannot be reached.
let spacing = tableView.intercellSpacing.width
let window = ColumnWindowResolver.resolve(
columnWidths: candidates.map { $0.width + spacing },
viewportMinX: viewport.minX - leadingChrome,
viewportMinX: viewport.minX - leadingChromeWidth(in: tableView, before: candidates[0]),
viewportWidth: viewport.width,
current: windowedRange
)
guard window.range != windowedRange else { return }
windowedRange = window.range
mount(window, over: candidates, in: tableView)
}

/// Mounts a column the window left out, so anything that reads its frame gets a real rect.
///
/// `rect(ofColumn:)` and `frameOfCell(atColumn:row:)` are both empty for a hidden column, so
/// `scrollColumnToVisible` scrolls to the document origin instead of the column, and the inline
/// editor's own empty-frame guard makes it open nothing at all (#2381).
/// - Returns: whether the window had to widen, so the caller can drop it and let the next
/// resolve pick a tight one instead of leaving the widened range mounted.
@discardableResult
func mountColumn(_ column: NSTableColumn, in tableView: NSTableView) -> Bool {
guard presentsColumn(column) else { return false }
let candidates = presentedColumns(in: tableView)
guard let position = candidates.firstIndex(of: column) else { return false }

let spacing = tableView.intercellSpacing.width
guard let window = ColumnWindowResolver.window(
containing: position,
columnWidths: candidates.map { $0.width + spacing },
current: windowedRange
) else { return false }
mount(window, over: candidates, in: tableView)
return true
}

/// The first and last columns the result presents, in display order. The pool owns these
/// because the spacers are attached columns too and one of them sits immediately before the
/// first data column, so no fixed position can name either end.
func firstPresentedColumnIndex(in tableView: NSTableView) -> Int? {
tableView.tableColumns.firstIndex { presentsColumn($0) }
}

func lastPresentedColumnIndex(in tableView: NSTableView) -> Int? {
tableView.tableColumns.lastIndex { presentsColumn($0) }
}

func nextPresentedColumnIndex(after index: Int, in tableView: NSTableView) -> Int? {
let start = max(0, index + 1)
guard start < tableView.tableColumns.count else { return nil }
return tableView.tableColumns[start...].firstIndex { presentsColumn($0) }
}

func previousPresentedColumnIndex(before index: Int, in tableView: NSTableView) -> Int? {
let end = min(max(0, index), tableView.tableColumns.count)
guard end > 0 else { return nil }
return tableView.tableColumns[..<end].lastIndex { presentsColumn($0) }
}

func presentsColumn(atTableColumnIndex index: Int, in tableView: NSTableView) -> Bool {
guard index >= 0, index < tableView.tableColumns.count else { return false }
return presentsColumn(tableView.tableColumns[index])
}

private func presentedColumns(in tableView: NSTableView) -> [NSTableColumn] {
tableView.tableColumns.filter { presentsColumn($0) }
}

private func mount(
_ window: ColumnWindowResolver.Window,
over candidates: [NSTableColumn],
in tableView: NSTableView
) {
windowedRange = window.range
for (index, column) in candidates.enumerated() {
let mounted = window.range.contains(index)
if column.isHidden == mounted {
column.isHidden = !mounted
}
}
let spacing = tableView.intercellSpacing.width
applySpacer(leadingSpacer, width: spacerWidth(window.leadingWidth, spacing: spacing), in: tableView)
applySpacer(trailingSpacer, width: spacerWidth(window.trailingWidth, spacing: spacing), in: tableView)
}

/// The document starts at the row-number column, but the resolver measures from the first data
/// column, so the viewport has to be rebased before it can index into those widths.
///
/// Only chrome counts. The leading spacer sits ahead of the first data column and holds exactly
/// the width of the columns the window left out, which the resolver's own model already carries,
/// so counting it here subtracts that width twice and walks the window left while the reader
/// scrolls right, until it parks off screen and the grid paints nothing (#2381).
private func leadingChromeWidth(in tableView: NSTableView, before firstColumn: NSTableColumn) -> CGFloat {
tableView.tableColumns
.prefix { $0.identifier != firstColumn.identifier }
.filter { !$0.isHidden && !ColumnIdentitySchema.isSpacer($0.identifier) }
.reduce(0) { $0 + $1.width + tableView.intercellSpacing.width }
}

/// The resolver works in per-column slots that already include one gap each. A spacer is a
/// single column, so it keeps one gap of its own and absorbs the rest as width.
private func spacerWidth(_ slotWidth: CGFloat, spacing: CGFloat) -> CGFloat {
Expand Down
Loading
Loading