From 63f19098251bca87d44b7ded590475bd38ee83a1 Mon Sep 17 00:00:00 2001 From: andiahmads Date: Thu, 9 Jul 2026 13:53:07 +0700 Subject: [PATCH 1/3] test: rename password source command output tests --- ...s.swift => PasswordSourceResolverCommandOutputTests.swift} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename TableProTests/Core/Utilities/Connection/{PasswordSourceResolverTests.swift => PasswordSourceResolverCommandOutputTests.swift} (96%) diff --git a/TableProTests/Core/Utilities/Connection/PasswordSourceResolverTests.swift b/TableProTests/Core/Utilities/Connection/PasswordSourceResolverCommandOutputTests.swift similarity index 96% rename from TableProTests/Core/Utilities/Connection/PasswordSourceResolverTests.swift rename to TableProTests/Core/Utilities/Connection/PasswordSourceResolverCommandOutputTests.swift index 5fc4efc60..814fbe890 100644 --- a/TableProTests/Core/Utilities/Connection/PasswordSourceResolverTests.swift +++ b/TableProTests/Core/Utilities/Connection/PasswordSourceResolverCommandOutputTests.swift @@ -1,5 +1,5 @@ // -// PasswordSourceResolverTests.swift +// PasswordSourceResolverCommandOutputTests.swift // TableProTests // @@ -9,7 +9,7 @@ import Testing @testable import TablePro @Suite("PasswordSourceResolver command output") -struct PasswordSourceResolverTests { +struct PasswordSourceResolverCommandOutputTests { @Test("Command stdout is returned trimmed") func returnsTrimmedStdout() async throws { let output = try await PasswordSourceResolver.resolveCommand( From 2cedb01185d6fc6a1d1e08d7ba3e6923c029f39d Mon Sep 17 00:00:00 2001 From: andiahmads Date: Thu, 9 Jul 2026 13:53:13 +0700 Subject: [PATCH 2/3] fix(datagrid): show column comments in headers --- CHANGELOG.md | 4 + .../Views/Results/DataGridColumnPool.swift | 27 +++++++ .../Views/Results/SortableHeaderCell.swift | 65 ++++++++++++++-- .../Results/DataGridColumnPoolTests.swift | 77 ++++++++++++++++++- .../Results/SortableHeaderCellTests.swift | 12 ++- 5 files changed, 177 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38e7f48aa..6e58f707b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Data grid column comments now appear directly in column headers when object comments are enabled, instead of only being available from the header tooltip. (#1789) + ### Fixed - MongoDB filters and generated edit statements now quote numeric-looking values that would not round-trip, such as `.5`, `1.`, `+7`, `01`, integers larger than 64 bits, and exponents that overflow a double like `1e400`, instead of emitting invalid or lossy JSON. A row whose `_id` is a decimal or exponent string is matched as that string, so delete and update target the right document. Update the MongoDB plugin to get the fix. (#1813) diff --git a/TablePro/Views/Results/DataGridColumnPool.swift b/TablePro/Views/Results/DataGridColumnPool.swift index 7432d6e4a..1e9810a7c 100644 --- a/TablePro/Views/Results/DataGridColumnPool.swift +++ b/TablePro/Views/Results/DataGridColumnPool.swift @@ -65,6 +65,7 @@ final class DataGridColumnPool { column.isHidden = true } } + updateHeaderHeight(in: tableView, showsComments: hasVisibleComments(visibleCount: visibleCount)) let targetOrder = computeTargetOrder( visibleCount: visibleCount, @@ -189,6 +190,9 @@ final class DataGridColumnPool { cell.alignment = column.headerCell.alignment column.headerCell = cell } + if let headerCell = column.headerCell as? SortableHeaderCell { + headerCell.headerComment = comment + } var tooltip: String if let typeName = columnType?.rawType ?? columnType?.displayName { @@ -218,4 +222,27 @@ final class DataGridColumnPool { column.sortDescriptorPrototype = NSSortDescriptor(key: name, ascending: true) } } + + private func hasVisibleComments(visibleCount: Int) -> Bool { + pooledColumns.enumerated().contains { slot, column in + guard slot < visibleCount, + !column.isHidden, + let cell = column.headerCell as? SortableHeaderCell, + let comment = cell.headerComment else { + return false + } + return !comment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + } + + private func updateHeaderHeight(in tableView: NSTableView, showsComments: Bool) { + guard let headerView = tableView.headerView else { return } + let targetHeight = showsComments + ? SortableHeaderCell.commentHeaderHeight + : SortableHeaderCell.compactHeaderHeight + guard headerView.frame.height != targetHeight else { return } + var frame = headerView.frame + frame.size.height = targetHeight + headerView.frame = frame + } } diff --git a/TablePro/Views/Results/SortableHeaderCell.swift b/TablePro/Views/Results/SortableHeaderCell.swift index 6998850c0..418b01ceb 100644 --- a/TablePro/Views/Results/SortableHeaderCell.swift +++ b/TablePro/Views/Results/SortableHeaderCell.swift @@ -13,13 +13,18 @@ final class SortableHeaderCell: NSTableHeaderCell { var isValueFiltered: Bool = false var isFunnelVisible: Bool = false var supportsValueFilter: Bool = true + var headerComment: String? private static let indicatorPadding: CGFloat = 4 private static let indicatorSpacing: CGFloat = 2 private static let priorityFontSize: CGFloat = 9 + private static let commentFontSize: CGFloat = 10 + private static let commentLineSpacing: CGFloat = 1 private static let defaultIndicatorSize = NSSize(width: 9, height: 6) private static let funnelSize = NSSize(width: 13, height: 13) private static let funnelPointSize: CGFloat = 11 + static let compactHeaderHeight: CGFloat = 24 + static let commentHeaderHeight: CGFloat = 36 override init(textCell string: String) { super.init(textCell: string) @@ -45,7 +50,9 @@ final class SortableHeaderCell: NSTableHeaderCell { drawTitle( in: titleRect(forBounds: cellFrame), font: titleFont(isSorted: sortDirection != nil), - color: foreground + color: foreground, + comment: visibleComment, + commentColor: commentColor(emphasized: isColumnSelected) ) var trailingCursorX = cellFrame.maxX - Self.indicatorPadding @@ -152,30 +159,75 @@ final class SortableHeaderCell: NSTableHeaderCell { return NSFontManager.shared.convert(baseFont, toHaveTrait: .boldFontMask) } + private var visibleComment: String? { + guard let headerComment else { return nil } + let trimmed = headerComment.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + private func foregroundColor(emphasized: Bool) -> NSColor { emphasized ? .alternateSelectedControlTextColor : .headerTextColor } - private func drawTitle(in rect: NSRect, font titleFont: NSFont, color: NSColor) { + private func commentColor(emphasized: Bool) -> NSColor { + emphasized ? .alternateSelectedControlTextColor : .secondaryLabelColor + } + + private func drawTitle( + in rect: NSRect, + font titleFont: NSFont, + color: NSColor, + comment: String?, + commentColor: NSColor + ) { let paragraph = NSMutableParagraphStyle() paragraph.alignment = alignment paragraph.lineBreakMode = .byTruncatingTail - let attributes: [NSAttributedString.Key: Any] = [ + let titleAttributes: [NSAttributedString.Key: Any] = [ .font: titleFont, .foregroundColor: color, .paragraphStyle: paragraph ] - let title = NSAttributedString(string: stringValue, attributes: attributes) + let title = NSAttributedString(string: stringValue, attributes: titleAttributes) let textHeight = title.size().height + guard let comment else { + let drawRect = NSRect( + x: rect.minX, + y: rect.midY - textHeight / 2, + width: rect.width, + height: textHeight + ) + title.draw(in: drawRect) + return + } + + let commentAttributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: Self.commentFontSize), + .foregroundColor: commentColor, + .paragraphStyle: paragraph + ] + let commentText = NSAttributedString(string: comment, attributes: commentAttributes) + let commentHeight = commentText.size().height + let stackHeight = textHeight + Self.commentLineSpacing + commentHeight + let stackMinY = rect.midY - stackHeight / 2 + let drawRect = NSRect( x: rect.minX, - y: rect.midY - textHeight / 2, + y: stackMinY, width: rect.width, height: textHeight ) title.draw(in: drawRect) + + let commentRect = NSRect( + x: rect.minX, + y: stackMinY + textHeight + Self.commentLineSpacing, + width: rect.width, + height: commentHeight + ) + commentText.draw(in: commentRect) } override func drawSortIndicator( @@ -201,6 +253,9 @@ final class SortableHeaderCell: NSTableHeaderCell { if isValueFiltered { components.append(String(localized: "Filtered")) } + if let visibleComment { + components.append(visibleComment) + } return components.joined(separator: ", ") } diff --git a/TableProTests/Views/Results/DataGridColumnPoolTests.swift b/TableProTests/Views/Results/DataGridColumnPoolTests.swift index 1aa062ec2..23578d37c 100644 --- a/TableProTests/Views/Results/DataGridColumnPoolTests.swift +++ b/TableProTests/Views/Results/DataGridColumnPoolTests.swift @@ -318,7 +318,7 @@ struct DataGridColumnPoolTests { savedLayout: layout, isEditable: true, hiddenColumnNames: [], - widthCalculator: { _, _ in 9999 } + widthCalculator: { _, _ in 9_999 } ) let widthsByName = Dictionary(uniqueKeysWithValues: dataColumns(in: tableView).map { ($0.headerCell.stringValue, $0.width) }) @@ -326,6 +326,79 @@ struct DataGridColumnPoolTests { #expect(widthsByName["name"] == 250) } + @Test("reconcile assigns column comments to sortable header cells") + func reconcile_assignsColumnCommentsToHeaderCells() throws { + let pool = DataGridColumnPool() + let tableView = makeTableView() + 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 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(emailColumn.headerToolTip == "email (Text)\nPrimary contact address") + + pool.reconcile( + tableView: tableView, + schema: schema, + columnTypes: makeColumnTypes(count: 2), + columnComments: [:], + savedLayout: nil, + isEditable: true, + hiddenColumnNames: [], + widthCalculator: defaultWidthCalculator + ) + + #expect(headerCell.headerComment == nil) + #expect(emailColumn.headerToolTip == "email (Text)") + } + + @Test("reconcile expands and restores header height based on visible comments") + func reconcile_updatesHeaderHeightForVisibleComments() { + let pool = DataGridColumnPool() + let tableView = makeTableView() + tableView.headerView = SortableHeaderView( + frame: NSRect(x: 0, y: 0, width: 200, height: SortableHeaderCell.compactHeaderHeight) + ) + 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 + ) + + #expect(tableView.headerView?.frame.height == SortableHeaderCell.commentHeaderHeight) + + pool.reconcile( + tableView: tableView, + schema: schema, + columnTypes: makeColumnTypes(count: 2), + columnComments: ["email": "Primary contact address"], + savedLayout: nil, + isEditable: true, + hiddenColumnNames: ["email"], + widthCalculator: defaultWidthCalculator + ) + + #expect(tableView.headerView?.frame.height == SortableHeaderCell.compactHeaderHeight) + } + @Test("reconcile is idempotent for equivalent inputs") func reconcile_isIdempotentForEquivalentInputs() { let pool = DataGridColumnPool() @@ -384,7 +457,7 @@ struct DataGridColumnPoolTests { #expect(dataColumns(in: tableView).count == 3) pool.detachFromTableView() - #expect(dataColumns(in: tableView).count == 0) + #expect(dataColumns(in: tableView).isEmpty) #expect(pool.totalSlots == 3) pool.reconcile( diff --git a/TableProTests/Views/Results/SortableHeaderCellTests.swift b/TableProTests/Views/Results/SortableHeaderCellTests.swift index 0927c0b9c..7b4c28273 100644 --- a/TableProTests/Views/Results/SortableHeaderCellTests.swift +++ b/TableProTests/Views/Results/SortableHeaderCellTests.swift @@ -1,8 +1,9 @@ import AppKit import TableProPluginKit -@testable import TablePro import Testing +@testable import TablePro + @MainActor @Suite("SortableHeaderCell") struct SortableHeaderCellTests { @@ -57,6 +58,15 @@ struct SortableHeaderCellTests { #expect(prioritizedWidth < sortedWidth) } + + @Test("Accessibility label includes the visible header comment") + func accessibilityLabelIncludesHeaderComment() { + let cell = SortableHeaderCell(textCell: "email") + cell.headerComment = "Primary contact address" + cell.setAccessibilityLabel("Column: email") + + #expect(cell.accessibilityLabel() == "Column: email, Primary contact address") + } } @MainActor From 2713fe924f36eb6040f7ea19d5624451a922c8a7 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 10 Jul 2026 14:36:53 +0700 Subject: [PATCH 3/3] fix(datagrid): enforce comment header height so it survives scroll and resize --- .../Views/Results/DataGridColumnPool.swift | 9 +- .../Views/Results/SortableHeaderCell.swift | 2 - .../Views/Results/SortableHeaderView.swift | 36 ++++++++ ...wordSourceResolverCommandOutputTests.swift | 82 ------------------- .../Results/DataGridColumnPoolTests.swift | 7 +- 5 files changed, 41 insertions(+), 95 deletions(-) delete mode 100644 TableProTests/Core/Utilities/Connection/PasswordSourceResolverCommandOutputTests.swift diff --git a/TablePro/Views/Results/DataGridColumnPool.swift b/TablePro/Views/Results/DataGridColumnPool.swift index 1e9810a7c..088367d45 100644 --- a/TablePro/Views/Results/DataGridColumnPool.swift +++ b/TablePro/Views/Results/DataGridColumnPool.swift @@ -236,13 +236,6 @@ final class DataGridColumnPool { } private func updateHeaderHeight(in tableView: NSTableView, showsComments: Bool) { - guard let headerView = tableView.headerView else { return } - let targetHeight = showsComments - ? SortableHeaderCell.commentHeaderHeight - : SortableHeaderCell.compactHeaderHeight - guard headerView.frame.height != targetHeight else { return } - var frame = headerView.frame - frame.size.height = targetHeight - headerView.frame = frame + (tableView.headerView as? SortableHeaderView)?.showsComments = showsComments } } diff --git a/TablePro/Views/Results/SortableHeaderCell.swift b/TablePro/Views/Results/SortableHeaderCell.swift index 418b01ceb..8850d981b 100644 --- a/TablePro/Views/Results/SortableHeaderCell.swift +++ b/TablePro/Views/Results/SortableHeaderCell.swift @@ -23,8 +23,6 @@ final class SortableHeaderCell: NSTableHeaderCell { private static let defaultIndicatorSize = NSSize(width: 9, height: 6) private static let funnelSize = NSSize(width: 13, height: 13) private static let funnelPointSize: CGFloat = 11 - static let compactHeaderHeight: CGFloat = 24 - static let commentHeaderHeight: CGFloat = 36 override init(textCell string: String) { super.init(textCell: string) diff --git a/TablePro/Views/Results/SortableHeaderView.swift b/TablePro/Views/Results/SortableHeaderView.swift index e3b7aa571..0dc900f73 100644 --- a/TablePro/Views/Results/SortableHeaderView.swift +++ b/TablePro/Views/Results/SortableHeaderView.swift @@ -63,22 +63,58 @@ 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 private var pendingClickStartLocation: NSPoint? private var dragOccurredDuringClick = false 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 showsComments = false { + didSet { + guard showsComments != oldValue else { return } + applyHeaderHeight() + } + } + override init(frame frameRect: NSRect) { + naturalHeight = frameRect.height > 0 ? frameRect.height : Self.fallbackHeight super.init(frame: frameRect) } required init?(coder: NSCoder) { + naturalHeight = Self.fallbackHeight 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 + if frame.height != targetHeight { + setFrameSize(NSSize(width: frame.width, height: targetHeight)) + } + enclosingScrollView?.tile() + needsDisplay = true + } + override func updateTrackingAreas() { super.updateTrackingAreas() if let existing = mouseMovedTrackingArea { diff --git a/TableProTests/Core/Utilities/Connection/PasswordSourceResolverCommandOutputTests.swift b/TableProTests/Core/Utilities/Connection/PasswordSourceResolverCommandOutputTests.swift deleted file mode 100644 index 814fbe890..000000000 --- a/TableProTests/Core/Utilities/Connection/PasswordSourceResolverCommandOutputTests.swift +++ /dev/null @@ -1,82 +0,0 @@ -// -// PasswordSourceResolverCommandOutputTests.swift -// TableProTests -// - -import Foundation -import Testing - -@testable import TablePro - -@Suite("PasswordSourceResolver command output") -struct PasswordSourceResolverCommandOutputTests { - @Test("Command stdout is returned trimmed") - func returnsTrimmedStdout() async throws { - let output = try await PasswordSourceResolver.resolveCommand( - shell: "printf ' hunter2\\n'", - timeoutSeconds: 5 - ) - #expect(output == "hunter2") - } - - @Test("Large stdout drains in full and in order while stderr is also draining") - func drainsLargeStdoutWithoutTruncation() async throws { - let shell = """ - head -c 300000 /dev/zero | tr '\\0' 'a' - head -c 300000 /dev/zero | tr '\\0' 'b' >&2 - """ - let output = try await PasswordSourceResolver.resolveCommand(shell: shell, timeoutSeconds: 30) - #expect(output.count == 300_000) - #expect(output.allSatisfy { $0 == "a" }) - } - - @Test("A failing command surfaces the exit code and stderr") - func failingCommandSurfacesStderr() async throws { - do { - _ = try await PasswordSourceResolver.resolveCommand( - shell: "printf boom >&2; exit 7", - timeoutSeconds: 5 - ) - Issue.record("Expected resolveCommand to throw") - } catch let PasswordSourceResolver.ResolutionError.commandFailed(exitCode, stderr) { - #expect(exitCode == 7) - #expect(stderr.contains("boom")) - } - } - - @Test("Output over the size cap fails as too large") - func overflowFailsAsTooLarge() async throws { - do { - _ = try await PasswordSourceResolver.resolveCommand( - shell: "yes | head -c 1200000", - timeoutSeconds: 30 - ) - Issue.record("Expected outputTooLarge") - } catch PasswordSourceResolver.ResolutionError.outputTooLarge { - } - } - - @Test("A command that exceeds the timeout is terminated") - func timeoutTerminatesCommand() async throws { - do { - _ = try await PasswordSourceResolver.resolveCommand( - shell: "sleep 30", - timeoutSeconds: 1 - ) - Issue.record("Expected commandTimedOut") - } catch PasswordSourceResolver.ResolutionError.commandTimedOut { - } - } - - @Test("Empty output fails as an empty password") - func emptyOutputFailsAsEmpty() async throws { - do { - _ = try await PasswordSourceResolver.resolveCommand( - shell: "true", - timeoutSeconds: 5 - ) - Issue.record("Expected emptyPassword") - } catch PasswordSourceResolver.ResolutionError.emptyPassword { - } - } -} diff --git a/TableProTests/Views/Results/DataGridColumnPoolTests.swift b/TableProTests/Views/Results/DataGridColumnPoolTests.swift index 23578d37c..394d532cc 100644 --- a/TableProTests/Views/Results/DataGridColumnPoolTests.swift +++ b/TableProTests/Views/Results/DataGridColumnPoolTests.swift @@ -367,8 +367,9 @@ struct DataGridColumnPoolTests { func reconcile_updatesHeaderHeightForVisibleComments() { let pool = DataGridColumnPool() let tableView = makeTableView() + let naturalHeight: CGFloat = 28 tableView.headerView = SortableHeaderView( - frame: NSRect(x: 0, y: 0, width: 200, height: SortableHeaderCell.compactHeaderHeight) + frame: NSRect(x: 0, y: 0, width: 200, height: naturalHeight) ) let schema = ColumnIdentitySchema(columns: ["id", "email"]) @@ -383,7 +384,7 @@ struct DataGridColumnPoolTests { widthCalculator: defaultWidthCalculator ) - #expect(tableView.headerView?.frame.height == SortableHeaderCell.commentHeaderHeight) + #expect(tableView.headerView?.frame.height == SortableHeaderView.commentHeaderHeight) pool.reconcile( tableView: tableView, @@ -396,7 +397,7 @@ struct DataGridColumnPoolTests { widthCalculator: defaultWidthCalculator ) - #expect(tableView.headerView?.frame.height == SortableHeaderCell.compactHeaderHeight) + #expect(tableView.headerView?.frame.height == naturalHeight) } @Test("reconcile is idempotent for equivalent inputs")