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

### Fixed

- **Size to Fit** and **Size All Columns to Fit** no longer stretch a column with long text far past the window. A fitted column now stops at half the visible grid, and every column has a maximum width, so a column left oversized before is brought back in range the next time you open the table.
- The username is now optional. Leaving it empty, or importing a connection URL that has no user in it, no longer fills in `root`. An empty username lets the database use its own default, the same as `psql` and `mysql` do: your Mac login name on MySQL/MariaDB and PostgreSQL, and `default` on ClickHouse. The `~/.pgpass` lookup now matches on that same user.
- A failed or cancelled connection that uses a Cloudflare tunnel no longer leaves the `cloudflared` process running in the background.
- Pinned results are no longer discarded by **Clear Results**, and a tab holding one is no longer reused to browse a different table. (#1855)
Expand Down
2 changes: 2 additions & 0 deletions TablePro/Views/Results/Cells/DataGridMetrics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,6 @@ enum DataGridMetrics {
static let cellHorizontalInset: CGFloat = 4
static let rowNumberHeaderPadding: CGFloat = 8
static let rowNumberColumnMinWidth: CGFloat = 40
static let dataColumnMinWidth: CGFloat = 30
static let dataColumnMaxWidth: CGFloat = 1_200
}
83 changes: 46 additions & 37 deletions TablePro/Views/Results/DataGridCellFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,70 +11,79 @@ import TableProPluginKit
final class DataGridCellFactory {
private static let minColumnWidth: CGFloat = 60
private static let maxColumnWidth: CGFloat = 800
private static let minFitToContentWidth: CGFloat = 300
private static let fitToContentViewportFraction: CGFloat = 0.5
private static let sampleRowCount = 30
private static let maxMeasureChars = 50
private static let headerPadding: CGFloat = 48
private static let cellPadding: CGFloat = 16
private static let headerCharWidthRatio: CGFloat = 0.75

private static let headerFont = NSFont.systemFont(ofSize: 13, weight: .semibold)

func calculateColumnWidth(for columnName: String) -> CGFloat {
let attributes: [NSAttributedString.Key: Any] = [.font: Self.headerFont]
let size = (columnName as NSString).size(withAttributes: attributes)
let width = size.width + 48
return min(max(width, Self.minColumnWidth), Self.maxColumnWidth)
static func fitToContentCap(availableWidth: CGFloat) -> CGFloat {
let proportional = availableWidth * fitToContentViewportFraction
return min(max(proportional, minFitToContentWidth), maxColumnWidth)
}

func calculateOptimalColumnWidth(
for columnName: String,
columnIndex: Int,
tableRows: TableRows
) -> CGFloat {
let headerCharCount = (columnName as NSString).length
var maxWidth = CGFloat(headerCharCount) * ThemeEngine.shared.dataGridFonts.monoCharWidth * 0.75 + 48
measureColumnWidth(
for: columnName,
columnIndex: columnIndex,
tableRows: tableRows,
cap: Self.maxColumnWidth,
measuredCharLimit: Self.maxMeasureChars
)
}

let totalRows = tableRows.count
let columnCount = tableRows.columns.count
let effectiveSampleCount = columnCount > 50 ? 10 : Self.sampleRowCount
let step = max(1, totalRows / effectiveSampleCount)
func calculateFitToContentWidth(
for columnName: String,
columnIndex: Int,
tableRows: TableRows,
availableWidth: CGFloat
) -> CGFloat {
let cap = Self.fitToContentCap(availableWidth: availableWidth)
let charWidth = ThemeEngine.shared.dataGridFonts.monoCharWidth

for i in stride(from: 0, to: totalRows, by: step) {
guard let value = tableRows.value(at: i, column: columnIndex).asText else { continue }

let charCount = min((value as NSString).length, Self.maxMeasureChars)
let cellWidth = CGFloat(charCount) * charWidth + 16
maxWidth = max(maxWidth, cellWidth)

if maxWidth >= Self.maxColumnWidth {
return Self.maxColumnWidth
}
}

return min(max(maxWidth, Self.minColumnWidth), Self.maxColumnWidth)
let measuredCharLimit = charWidth > 0 ? Int((cap / charWidth).rounded(.up)) : Self.maxMeasureChars

return measureColumnWidth(
for: columnName,
columnIndex: columnIndex,
tableRows: tableRows,
cap: cap,
measuredCharLimit: measuredCharLimit
)
}

func calculateFitToContentWidth(
private func measureColumnWidth(
for columnName: String,
columnIndex: Int,
tableRows: TableRows
tableRows: TableRows,
cap: CGFloat,
measuredCharLimit: Int
) -> CGFloat {
let charWidth = ThemeEngine.shared.dataGridFonts.monoCharWidth
let headerCharCount = (columnName as NSString).length
var maxWidth = CGFloat(headerCharCount) * ThemeEngine.shared.dataGridFonts.monoCharWidth * 0.75 + 48
var maxWidth = CGFloat(headerCharCount) * charWidth * Self.headerCharWidthRatio + Self.headerPadding

let totalRows = tableRows.count
let columnCount = tableRows.columns.count
let effectiveSampleCount = columnCount > 50 ? 10 : Self.sampleRowCount
let effectiveSampleCount = tableRows.columns.count > 50 ? 10 : Self.sampleRowCount
let step = max(1, totalRows / effectiveSampleCount)
let charWidth = ThemeEngine.shared.dataGridFonts.monoCharWidth

for i in stride(from: 0, to: totalRows, by: step) {
guard let value = tableRows.value(at: i, column: columnIndex).asText else { continue }

let charCount = (value as NSString).length
let cellWidth = CGFloat(charCount) * charWidth + 16
maxWidth = max(maxWidth, cellWidth)
let charCount = min((value as NSString).length, measuredCharLimit)
maxWidth = max(maxWidth, CGFloat(charCount) * charWidth + Self.cellPadding)

if maxWidth >= cap {
return cap
}
}

return max(maxWidth, Self.minColumnWidth)
return min(max(maxWidth, Self.minColumnWidth), cap)
}
}

Expand Down
3 changes: 2 additions & 1 deletion TablePro/Views/Results/DataGridColumnPool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ final class DataGridColumnPool {
while pooledColumns.count < count {
let slot = pooledColumns.count
let column = NSTableColumn(identifier: ColumnIdentitySchema.slotIdentifier(slot))
column.minWidth = 30
column.minWidth = DataGridMetrics.dataColumnMinWidth
column.maxWidth = DataGridMetrics.dataColumnMaxWidth
column.resizingMask = .userResizingMask
column.isEditable = true
column.isHidden = true
Expand Down
45 changes: 28 additions & 17 deletions TablePro/Views/Results/Extensions/DataGridView+Sort.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,25 @@ extension TableViewCoordinator {
return column.width
}

let tableRows = tableRowsProvider()
let width = cellFactory.calculateFitToContentWidth(
return fitToContentWidth(for: column, dataColumnIndex: dataColumnIndex, tableRows: tableRowsProvider())
}

private var visibleGridWidth: CGFloat {
guard let tableView else { return 0 }
return tableView.enclosingScrollView?.contentView.bounds.width ?? tableView.visibleRect.width
}

private func fitToContentWidth(
for column: NSTableColumn,
dataColumnIndex: Int,
tableRows: TableRows
) -> CGFloat {
cellFactory.calculateFitToContentWidth(
for: dataColumnIndex < tableRows.columns.count ? tableRows.columns[dataColumnIndex] : column.title,
columnIndex: dataColumnIndex,
tableRows: tableRows
tableRows: tableRows,
availableWidth: visibleGridWidth
)
return width
}

// MARK: - NSMenuDelegate (Header Context Menu)
Expand Down Expand Up @@ -294,29 +306,28 @@ extension TableViewCoordinator {
let column = tableView.tableColumns[columnIndex]
guard let dataColumnIndex = dataColumnIndex(from: column.identifier) else { return }

let tableRows = tableRowsProvider()
let width = cellFactory.calculateFitToContentWidth(
for: dataColumnIndex < tableRows.columns.count ? tableRows.columns[dataColumnIndex] : column.title,
columnIndex: dataColumnIndex,
tableRows: tableRows
column.width = fitToContentWidth(
for: column,
dataColumnIndex: dataColumnIndex,
tableRows: tableRowsProvider()
)
column.width = width
}

@objc func sizeAllColumnsToFit(_ sender: NSMenuItem) {
guard let tableView else { return }

let tableRows = tableRowsProvider()
for column in tableView.tableColumns {
guard column.identifier != ColumnIdentitySchema.rowNumberIdentifier,
let dataColumnIndex = dataColumnIndex(from: column.identifier) else { continue }

let width = cellFactory.calculateFitToContentWidth(
for: dataColumnIndex < tableRows.columns.count ? tableRows.columns[dataColumnIndex] : column.title,
columnIndex: dataColumnIndex,
guard !column.isHidden,
column.identifier != ColumnIdentitySchema.rowNumberIdentifier,
let dataColumnIndex = dataColumnIndex(from: column.identifier),
dataColumnIndex < tableRows.columns.count else { continue }

column.width = fitToContentWidth(
for: column,
dataColumnIndex: dataColumnIndex,
tableRows: tableRows
)
column.width = width
}
}

Expand Down
80 changes: 67 additions & 13 deletions TableProTests/Views/Results/DataGridCellFactoryPerfTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@

import Foundation
import TableProPluginKit
@testable import TablePro
import Testing

@testable import TablePro

@Suite("Column Width Optimization")
@MainActor
struct ColumnWidthOptimizationTests {
Expand Down Expand Up @@ -103,18 +104,6 @@ struct ColumnWidthOptimizationTests {
}
}

@Test("Width based on header-only method matches expected bounds")
func headerOnlyWidthCalculation() {
let factory = DataGridCellFactory()

let shortWidth = factory.calculateColumnWidth(for: "id")
#expect(shortWidth >= 60)

let longWidth = factory.calculateColumnWidth(for: "a_very_long_column_name_that_is_descriptive")
#expect(longWidth > shortWidth)
#expect(longWidth <= 800)
}

@Test("Nil cell values do not crash width calculation")
func nilCellValuesSafe() {
let factory = DataGridCellFactory()
Expand All @@ -139,6 +128,71 @@ struct ColumnWidthOptimizationTests {
}
}

@Suite("Fit To Content Width")
@MainActor
struct FitToContentWidthTests {
private func makeTableRows(values: [String], column: String = "data") -> TableRows {
TableRows.from(
queryRows: values.map { [PluginCellValue.fromOptional($0)] },
columns: [column],
columnTypes: [.text(rawType: nil)]
)
}

private func fitWidth(values: [String], availableWidth: CGFloat, column: String = "data") -> CGFloat {
DataGridCellFactory().calculateFitToContentWidth(
for: column,
columnIndex: 0,
tableRows: makeTableRows(values: values, column: column),
availableWidth: availableWidth
)
}

@Test("A very long value is capped instead of stretching the column")
func longValueIsCapped() {
let width = fitWidth(values: [String(repeating: "X", count: 5_000)], availableWidth: 1_600)

#expect(width == 800, "A 5,000 character value must not widen the column past the 800pt ceiling")
}

@Test("No column takes more than half the visible grid")
func capIsHalfTheVisibleGrid() {
let width = fitWidth(values: [String(repeating: "X", count: 5_000)], availableWidth: 1_000)

#expect(width == 500)
}

@Test("Cap holds at 300pt in a narrow window")
func capFloorsInNarrowWindow() {
#expect(fitWidth(values: [String(repeating: "X", count: 5_000)], availableWidth: 200) == 300)
#expect(fitWidth(values: [String(repeating: "X", count: 5_000)], availableWidth: 0) == 300)
}

@Test("Short values still size to their content, not to the cap")
func shortValuesSizeToContent() {
let width = fitWidth(values: ["ok", "fine"], availableWidth: 1_600, column: "status")

#expect(width >= 60)
#expect(width < 300, "The cap is a ceiling, not a target width")
}

@Test("Fitted width never exceeds the data column ceiling")
func fittedWidthStaysUnderColumnCeiling() {
for availableWidth in [CGFloat](stride(from: 0, through: 4_000, by: 250)) {
let width = fitWidth(values: [String(repeating: "X", count: 20_000)], availableWidth: availableWidth)
#expect(width <= DataGridMetrics.dataColumnMaxWidth)
}
}

@Test("Long header alone does not stretch the column")
func longHeaderIsCapped() {
let column = String(repeating: "column_name_", count: 200)
let width = fitWidth(values: [], availableWidth: 1_600, column: column)

#expect(width == 800)
}
}

@Suite("Change Reapplication Version Tracking")
struct ChangeReapplyVersionTests {
@Test("Version tracking skips redundant work")
Expand Down
45 changes: 45 additions & 0 deletions TableProTests/Views/Results/DataGridColumnPoolTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,51 @@ struct DataGridColumnPoolTests {
#expect(widthsByName["name"] == 250)
}

@Test("An oversized saved width is clamped to the column ceiling")
func reconcile_clampsOversizedSavedWidthToColumnCeiling() {
let pool = DataGridColumnPool()
let tableView = makeTableView()
let schema = ColumnIdentitySchema(columns: ["id", "payload"])

var layout = ColumnLayoutState()
layout.columnWidths = ["id": 75, "payload": 28_000]

pool.reconcile(
tableView: tableView,
schema: schema,
columnTypes: makeColumnTypes(count: 2),
savedLayout: layout,
isEditable: true,
hiddenColumnNames: [],
widthCalculator: defaultWidthCalculator
)

let widthsByName = Dictionary(uniqueKeysWithValues: dataColumns(in: tableView).map { ($0.headerCell.stringValue, $0.width) })
#expect(widthsByName["id"] == 75)
#expect(widthsByName["payload"] == DataGridMetrics.dataColumnMaxWidth)
}

@Test("Data columns carry the min and max width bounds")
func reconcile_dataColumnsCarryWidthBounds() {
let pool = DataGridColumnPool()
let tableView = makeTableView()

pool.reconcile(
tableView: tableView,
schema: ColumnIdentitySchema(columns: ["id", "name"]),
columnTypes: makeColumnTypes(count: 2),
savedLayout: nil,
isEditable: true,
hiddenColumnNames: [],
widthCalculator: defaultWidthCalculator
)

for column in dataColumns(in: tableView) {
#expect(column.minWidth == DataGridMetrics.dataColumnMinWidth)
#expect(column.maxWidth == DataGridMetrics.dataColumnMaxWidth)
}
}

@Test("reconcile assigns column comments to sortable header cells")
func reconcile_assignsColumnCommentsToHeaderCells() throws {
let pool = DataGridColumnPool()
Expand Down
Loading