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

- Column comments now show in the data grid header as soon as the table opens, instead of only after the query was run again. The header also grows to fit the comment line, so it is no longer cut off until a column is resized. (#1861)
- **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.
Expand Down
18 changes: 14 additions & 4 deletions TablePro/Views/Results/DataGridColumnPool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ final class DataGridColumnPool {

let willRestoreWidths = !(savedLayout?.columnWidths.isEmpty ?? true)
let hiddenFromLayout = savedLayout?.hiddenColumns ?? []
var commentsChanged = false

for slot in 0..<pooledColumns.count {
let column = pooledColumns[slot]
Expand All @@ -49,14 +50,16 @@ final class DataGridColumnPool {
let resolvedWidth = willRestoreWidths
? (savedLayout?.columnWidths[columnName] ?? widthCalculator(columnName, slot))
: widthCalculator(columnName, slot)
configureColumn(
if configureColumn(
column,
name: columnName,
columnType: slot < columnTypes.count ? columnTypes[slot] : nil,
comment: columnComments[columnName],
width: resolvedWidth,
isEditable: isEditable
)
) {
commentsChanged = true
}
let hidden = hiddenFromLayout.contains(columnName) || hiddenColumnNames.contains(columnName)
if column.isHidden != hidden {
column.isHidden = hidden
Expand All @@ -66,6 +69,9 @@ final class DataGridColumnPool {
}
}
updateHeaderHeight(in: tableView, showsComments: hasVisibleComments(visibleCount: visibleCount))
if commentsChanged {
tableView.headerView?.needsDisplay = true
}

let targetOrder = computeTargetOrder(
visibleCount: visibleCount,
Expand Down Expand Up @@ -184,15 +190,17 @@ final class DataGridColumnPool {
comment: String?,
width: CGFloat,
isEditable: Bool
) {
) -> Bool {
if !(column.headerCell is SortableHeaderCell) || column.headerCell.stringValue != name {
let cell = SortableHeaderCell(textCell: name)
cell.font = column.headerCell.font
cell.alignment = column.headerCell.alignment
column.headerCell = cell
}
if let headerCell = column.headerCell as? SortableHeaderCell {
var commentChanged = false
if let headerCell = column.headerCell as? SortableHeaderCell, headerCell.headerComment != comment {
headerCell.headerComment = comment
commentChanged = true
}

var tooltip: String
Expand Down Expand Up @@ -222,6 +230,8 @@ final class DataGridColumnPool {
if column.sortDescriptorPrototype?.key != name {
column.sortDescriptorPrototype = NSSortDescriptor(key: name, ascending: true)
}

return commentChanged
}

private func hasVisibleComments(visibleCount: Int) -> Bool {
Expand Down
2 changes: 1 addition & 1 deletion TablePro/Views/Results/DataGridUpdateSnapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,5 @@ struct DataGridUpdateSnapshot: Equatable {
let alternatingRows: Bool
let reloadVersion: Int
let contentRevision: Int
let showObjectComments: Bool
let columnComments: [String: String]
}
37 changes: 23 additions & 14 deletions TablePro/Views/Results/DataGridView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,15 @@ struct DataGridView: NSViewRepresentable {
tableView.addTableColumn(rowNumberColumn)
rowNumberColumn.isHidden = !configuration.showRowNumbers

let sortableHeader = SortableHeaderView(frame: tableView.headerView?.frame ?? .zero)
sortableHeader.coordinator = context.coordinator
let headerMenu = NSMenu()
headerMenu.delegate = context.coordinator
sortableHeader.menu = headerMenu
tableView.headerView = sortableHeader

scrollView.documentView = tableView

let initialRows = tableRowsProvider()
context.coordinator.rebuildColumnMetadataCache(from: initialRows)

Expand All @@ -89,24 +98,17 @@ struct DataGridView: NSViewRepresentable {
tableView: tableView,
coordinator: context.coordinator,
tableRows: initialRows,
columnComments: Self.effectiveColumnComments(for: initialRows),
savedLayout: initialLayout
)
context.coordinator.isRebuildingColumns = false

let sortableHeader = SortableHeaderView(frame: tableView.headerView?.frame ?? .zero)
sortableHeader.coordinator = context.coordinator
let headerMenu = NSMenu()
headerMenu.delegate = context.coordinator
sortableHeader.menu = headerMenu
tableView.headerView = sortableHeader

let hasMoveRow = delegate != nil
if hasMoveRow {
tableView.registerForDraggedTypes([NSPasteboard.PasteboardType("com.TablePro.rowDrag")])
tableView.draggingDestinationFeedbackStyle = .gap
}

scrollView.documentView = tableView
context.coordinator.tableView = tableView
installSelectionOverlay(tableView: tableView, coordinator: context.coordinator)
context.coordinator.attachScrollObservers(scrollView: scrollView)
Expand Down Expand Up @@ -156,6 +158,7 @@ struct DataGridView: NSViewRepresentable {
let settings = AppSettingsManager.shared.dataGrid
let rowHeight = CGFloat(settings.rowHeight.rawValue)
let alternatingRows = settings.showAlternateRows
let columnComments = Self.effectiveColumnComments(for: latestRows)

let snapshot = DataGridUpdateSnapshot(
rowDisplayCount: rowDisplayCount,
Expand All @@ -171,7 +174,7 @@ struct DataGridView: NSViewRepresentable {
alternatingRows: alternatingRows,
reloadVersion: changeManager.reloadVersion,
contentRevision: contentRevision,
showObjectComments: AppSettingsManager.shared.general.showObjectComments
columnComments: columnComments
)

if snapshot != coordinator.lastUpdateSnapshot {
Expand All @@ -186,7 +189,8 @@ struct DataGridView: NSViewRepresentable {
rowHeight: rowHeight,
alternatingRows: alternatingRows,
hasMoveDelegate: snapshot.hasMoveDelegate,
contentChanged: contentChanged
contentChanged: contentChanged,
columnComments: columnComments
)
coordinator.lastUpdateSnapshot = snapshot
}
Expand All @@ -204,7 +208,8 @@ struct DataGridView: NSViewRepresentable {
rowHeight: CGFloat,
alternatingRows: Bool,
hasMoveDelegate: Bool,
contentChanged: Bool
contentChanged: Bool,
columnComments: [String: String]
) {
if let rowNumCol = tableView.tableColumns.first(where: { $0.identifier == ColumnIdentitySchema.rowNumberIdentifier }) {
let shouldHide = !configuration.showRowNumbers
Expand Down Expand Up @@ -286,6 +291,7 @@ struct DataGridView: NSViewRepresentable {
tableView: tableView,
coordinator: coordinator,
tableRows: latestRows,
columnComments: columnComments,
savedLayout: savedLayout
)
coordinator.isRebuildingColumns = false
Expand Down Expand Up @@ -314,15 +320,18 @@ struct DataGridView: NSViewRepresentable {
coordinator.isApplyingProgrammaticRowSelection = false
}

private static func effectiveColumnComments(for tableRows: TableRows) -> [String: String] {
guard AppSettingsManager.shared.general.showObjectComments else { return [:] }
return tableRows.columnComments
}

private func reconcileColumnPool(
tableView: NSTableView,
coordinator: TableViewCoordinator,
tableRows: TableRows,
columnComments: [String: String],
savedLayout: ColumnLayoutState?
) {
let columnComments = AppSettingsManager.shared.general.showObjectComments
? tableRows.columnComments
: [:]
coordinator.columnPool.reconcile(
tableView: tableView,
schema: coordinator.identitySchema,
Expand Down
12 changes: 11 additions & 1 deletion TablePro/Views/Results/SortableHeaderCell.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@ final class SortableHeaderCell: NSTableHeaderCell {
private static let funnelSize = NSSize(width: 13, height: 13)
private static let funnelPointSize: CGFloat = 11

private static let commentFont = NSFont.systemFont(ofSize: commentFontSize)

static let commentLineHeight: CGFloat = {
let lineHeight = NSAttributedString(
string: "X",
attributes: [.font: commentFont]
).size().height
return (lineHeight + commentLineSpacing).rounded(.up)
}()

override init(textCell string: String) {
super.init(textCell: string)
lineBreakMode = .byTruncatingTail
Expand Down Expand Up @@ -202,7 +212,7 @@ final class SortableHeaderCell: NSTableHeaderCell {
}

let commentAttributes: [NSAttributedString.Key: Any] = [
.font: NSFont.systemFont(ofSize: Self.commentFontSize),
.font: Self.commentFont,
.foregroundColor: commentColor,
.paragraphStyle: paragraph
]
Expand Down
23 changes: 6 additions & 17 deletions TablePro/Views/Results/SortableHeaderView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@ enum HeaderSortCycle {
final class SortableHeaderView: NSTableHeaderView {
weak var coordinator: TableViewCoordinator?

static let commentHeaderHeight: CGFloat = 40
private static let clickDragThreshold: CGFloat = 4
private static let resizeZoneWidth: CGFloat = 4
private static let fallbackHeight: CGFloat = 28
Expand All @@ -73,14 +72,12 @@ final class SortableHeaderView: NSTableHeaderView {
private var mouseMovedTrackingArea: NSTrackingArea?
private var hoveredColumnIndex: Int?

/// Header height when no visible column carries a comment. Captured from the
/// height AppKit gave the header at creation so the compact layout keeps the
/// platform default instead of a hardcoded value.
private let naturalHeight: CGFloat

/// Grows the header to `commentHeaderHeight` so each cell can draw its comment
/// on a second line. Enforced through `setFrameSize` so `NSScrollView.tile()`
/// cannot shrink it back on scroll, live resize, or column drag.
var commentHeaderHeight: CGFloat {
naturalHeight + SortableHeaderCell.commentLineHeight
}

var showsComments = false {
didSet {
guard showsComments != oldValue else { return }
Expand All @@ -98,20 +95,12 @@ final class SortableHeaderView: NSTableHeaderView {
super.init(coder: coder)
}

override func setFrameSize(_ newSize: NSSize) {
var size = newSize
if showsComments {
size.height = Self.commentHeaderHeight
}
super.setFrameSize(size)
}

private func applyHeaderHeight() {
let targetHeight = showsComments ? Self.commentHeaderHeight : naturalHeight
let targetHeight = showsComments ? commentHeaderHeight : naturalHeight
if frame.height != targetHeight {
setFrameSize(NSSize(width: frame.width, height: targetHeight))
}
enclosingScrollView?.tile()
tableView?.enclosingScrollView?.tile()
needsDisplay = true
}

Expand Down
89 changes: 86 additions & 3 deletions TableProTests/Views/Results/DataGridColumnPoolTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -413,9 +413,10 @@ struct DataGridColumnPoolTests {
let pool = DataGridColumnPool()
let tableView = makeTableView()
let naturalHeight: CGFloat = 28
tableView.headerView = SortableHeaderView(
let headerView = SortableHeaderView(
frame: NSRect(x: 0, y: 0, width: 200, height: naturalHeight)
)
tableView.headerView = headerView
let schema = ColumnIdentitySchema(columns: ["id", "email"])

pool.reconcile(
Expand All @@ -429,7 +430,8 @@ struct DataGridColumnPoolTests {
widthCalculator: defaultWidthCalculator
)

#expect(tableView.headerView?.frame.height == SortableHeaderView.commentHeaderHeight)
#expect(headerView.commentHeaderHeight > naturalHeight)
#expect(headerView.frame.height == headerView.commentHeaderHeight)

pool.reconcile(
tableView: tableView,
Expand All @@ -442,7 +444,88 @@ struct DataGridColumnPoolTests {
widthCalculator: defaultWidthCalculator
)

#expect(tableView.headerView?.frame.height == naturalHeight)
#expect(headerView.frame.height == naturalHeight)
}

@Test("comments arriving after the first reconcile grow the header and repaint it")
func reconcile_appliesCommentsArrivingAfterFirstLoad() throws {
let pool = DataGridColumnPool()
let tableView = makeTableView()
let naturalHeight: CGFloat = 28
let headerView = SortableHeaderView(
frame: NSRect(x: 0, y: 0, width: 200, height: naturalHeight)
)
tableView.headerView = headerView
let schema = ColumnIdentitySchema(columns: ["id", "email"])

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

#expect(headerView.frame.height == naturalHeight)

headerView.needsDisplay = false
pool.reconcile(
tableView: tableView,
schema: schema,
columnTypes: makeColumnTypes(count: 2),
columnComments: ["email": "Primary contact address"],
savedLayout: nil,
isEditable: true,
hiddenColumnNames: [],
widthCalculator: defaultWidthCalculator
)

let emailColumn = try #require(dataColumns(in: tableView).first { $0.headerCell.stringValue == "email" })
let headerCell = try #require(emailColumn.headerCell as? SortableHeaderCell)
#expect(headerCell.headerComment == "Primary contact address")
#expect(headerView.frame.height == headerView.commentHeaderHeight)
}

@Test("an edited comment reaches the header cell without changing the header height")
func reconcile_appliesEditedCommentAtUnchangedHeaderHeight() throws {
let pool = DataGridColumnPool()
let tableView = makeTableView()
let headerView = SortableHeaderView(frame: NSRect(x: 0, y: 0, width: 200, height: 28))
tableView.headerView = headerView
let schema = ColumnIdentitySchema(columns: ["id", "email"])

pool.reconcile(
tableView: tableView,
schema: schema,
columnTypes: makeColumnTypes(count: 2),
columnComments: ["email": "Primary contact address"],
savedLayout: nil,
isEditable: true,
hiddenColumnNames: [],
widthCalculator: defaultWidthCalculator
)

let heightAfterFirstReconcile = headerView.frame.height

pool.reconcile(
tableView: tableView,
schema: schema,
columnTypes: makeColumnTypes(count: 2),
columnComments: ["email": "Work address"],
savedLayout: nil,
isEditable: true,
hiddenColumnNames: [],
widthCalculator: defaultWidthCalculator
)

let emailColumn = try #require(dataColumns(in: tableView).first { $0.headerCell.stringValue == "email" })
let headerCell = try #require(emailColumn.headerCell as? SortableHeaderCell)
#expect(headerCell.headerComment == "Work address")
#expect(emailColumn.headerToolTip == "email (Text)\nWork address")
#expect(headerView.frame.height == heightAfterFirstReconcile)
}

@Test("reconcile is idempotent for equivalent inputs")
Expand Down
Loading
Loading