diff --git a/CHANGELOG.md b/CHANGELOG.md index 78186367c..8c166cf88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - iOS: connections to `.local` (Bonjour) hostnames and other local-network addresses (10.x, 192.168.x, 172.16-31.x, 169.254.x, IPv6 ULA / link-local) timed out silently. The bundle was missing `NSLocalNetworkUsageDescription` and `NSBonjourServices`, so iOS never prompted the user for Local Network access and quietly dropped every outbound `connect()` to a local-network address. Most visible variant: SSH Tunnel set to `Some-MacBook.local`, error surfaced as "MySQL connection failed: Lost connection to server at 'handshake: reading initial communication packet', system error: 60" (errno 60 = `ETIMEDOUT`). Both Info.plist keys are now declared (purpose string explains database/SSH access; Bonjour types `_ssh._tcp`, `_mysql._tcp`, `_postgresql._tcp`, `_redis._tcp`). A new `LocalNetworkPermission` actor starts an `NWBrowser` for `_ssh._tcp` the first time a connection targets a local-network host (the documented Apple pattern from the DTS "Local Network Privacy FAQ" since a bare `connect()` does not always trigger the consent prompt for `getaddrinfo`-based connections), watches the `NWBrowser.State` stream for `.ready` (granted) or `.waiting`/`.failed` (unavailable), and caches the resolution per process. On denial the gate throws `LocalNetworkPermissionError.unavailable` immediately on every subsequent attempt instead of waiting for the 10-second TCP timeout, so the error surfaces in under a second. Concurrent first-time gate calls share one in-flight resolution `Task` so a parallel SSH + DB connect does not double-prompt. Wired through `SSHTunnelFactory.create()` and `MySQLDriver` / `PostgreSQLDriver` / `RedisDriver` `connect()` (loopback-only and non-local hosts no-op the gate). `ErrorClassifier` matches the typed `LocalNetworkPermissionError` directly and falls back to detecting `ETIMEDOUT` on SSH-enabled or local-network connections; both paths show "Open Settings > Privacy & Security > Local Network and turn TablePro on, then try again." instead of the previous generic "server is not responding" or misleading SSH-handshake copy. +- Data grid column headers now use the same 4pt horizontal inset as result cells, including the right-aligned `#` row-number header. - Toolbar connection status keeps its left inset when no connection tag is shown. - The SQL editor jumped to the end of the document after committing a Chinese (or any IME-marked) word like "测试". `TextView.insertText(_:replacementRange:)` called `unmarkText()` first, which wiped the marked text via `replaceCharacters(_:with: "")`, then re-ran `replaceCharacters` with the same range AppKit had supplied. By then that range was stale. For mid-document marked text it ate the next characters; for end-of-document marked text the second range was out of bounds and the cursor landed at `documentLength` (the visible "scroll to end"). The implementation now resolves the effective range(s) before clearing marked-text bookkeeping and performs a single edit, with a multi-cursor IME path that replaces every marked range in one pass. While here, `TextSelectionManager.didReplaceCharacters` had a latent off-by-N in its delta calculation that the old unmark-then-insert flow happened to mask; same-length replaces now leave selections past the replace point unchanged. Fixes #1012. - External-contributor builds under any non-official Apple Developer team (free personal team or otherwise) failed in three correlated ways: provisioning rejected the iCloud capability, `KeychainHelper`'s hardcoded `D7HJ5TFYCU.com.TablePro.shared` access group caused every keychain write to fail with `errSecMissingEntitlement` (visible as "Test Connection: Access denied (using password: NO)"), and `CloudKitSyncEngine.init` trapped on launch with `EXC_BREAKPOINT` because `CKContainer(identifier:)` requires the iCloud entitlement. `TablePro.entitlements` no longer hardcodes the team prefix (uses `$(AppIdentifierPrefix)` for the keychain group; `application-identifier` and `team-identifier` are removed since codesign auto-injects them). `KeychainHelper` resolves the access group at runtime from the running process's `keychain-access-groups` entitlement and falls back to the default group when none is declared. `CloudKitSyncEngine` and `SyncCoordinator.currentAccountId` detect the iCloud entitlement at runtime; sync methods throw `SyncError.accountUnavailable` when absent and the existing UI surfaces the disabled state. A new `TablePro/TablePro.Debug.entitlements` (identical to the default minus the iCloud keys) ships in the repo so contributors on personal teams can point their Debug build at it; `CONTRIBUTING.md` documents the one-time Xcode UI setup (Team, Bundle Identifier, Code Signing Entitlements path). Release and official-team Debug builds use `com.TablePro` and `TablePro/TablePro.entitlements` unchanged. Fixes #1020. diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 2cfe47df8..78d274025 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -74,6 +74,10 @@ struct DataGridView: NSViewRepresentable { rowNumberColumn.maxWidth = 60 rowNumberColumn.isEditable = false rowNumberColumn.resizingMask = [] + let rowNumberHeaderCell = SortableHeaderCell(textCell: "#") + rowNumberHeaderCell.font = rowNumberColumn.headerCell.font + rowNumberHeaderCell.alignment = .right + rowNumberColumn.headerCell = rowNumberHeaderCell rowNumberColumn.headerCell.setAccessibilityLabel(String(localized: "Row number")) tableView.addTableColumn(rowNumberColumn) rowNumberColumn.isHidden = !configuration.showRowNumbers diff --git a/TablePro/Views/Results/SortableHeaderCell.swift b/TablePro/Views/Results/SortableHeaderCell.swift index 41d20bf39..e0683a7c5 100644 --- a/TablePro/Views/Results/SortableHeaderCell.swift +++ b/TablePro/Views/Results/SortableHeaderCell.swift @@ -31,7 +31,7 @@ final class SortableHeaderCell: NSTableHeaderCell { override func drawInterior(withFrame cellFrame: NSRect, in controlView: NSView) { guard let direction = sortDirection else { - super.drawInterior(withFrame: cellFrame, in: controlView) + drawTitle(in: titleRect(forBounds: cellFrame), font: titleFont(isSorted: false)) return } @@ -49,7 +49,7 @@ final class SortableHeaderCell: NSTableHeaderCell { width: max(0, cellFrame.width - reservedWidth), height: cellFrame.height ) - drawSortedTitle(in: titleFrame) + drawTitle(in: titleRect(forBounds: titleFrame), font: titleFont(isSorted: true)) let indicatorOriginX = cellFrame.maxX - Self.indicatorPadding - indicatorSize.width let indicatorOriginY = cellFrame.midY - indicatorSize.height / 2 @@ -73,16 +73,29 @@ final class SortableHeaderCell: NSTableHeaderCell { } } - private func drawSortedTitle(in rect: NSRect) { + override func titleRect(forBounds rect: NSRect) -> NSRect { + let inset = min(Self.titleHorizontalPadding, rect.width / 2) + return NSRect( + x: rect.minX + inset, + y: rect.minY, + width: max(0, rect.width - inset * 2), + height: rect.height + ) + } + + private func titleFont(isSorted: Bool) -> NSFont { let baseFont = font ?? NSFont.systemFont(ofSize: NSFont.smallSystemFontSize) - let boldFont = NSFontManager.shared.convert(baseFont, toHaveTrait: .boldFontMask) + guard isSorted else { return baseFont } + return NSFontManager.shared.convert(baseFont, toHaveTrait: .boldFontMask) + } + private func drawTitle(in rect: NSRect, font titleFont: NSFont) { let paragraph = NSMutableParagraphStyle() paragraph.alignment = alignment paragraph.lineBreakMode = .byTruncatingTail let attributes: [NSAttributedString.Key: Any] = [ - .font: boldFont, + .font: titleFont, .foregroundColor: NSColor.headerTextColor, .paragraphStyle: paragraph ] @@ -90,9 +103,9 @@ final class SortableHeaderCell: NSTableHeaderCell { let title = NSAttributedString(string: stringValue, attributes: attributes) let textHeight = title.size().height let drawRect = NSRect( - x: rect.minX + Self.titleHorizontalPadding, + x: rect.minX, y: rect.midY - textHeight / 2, - width: max(0, rect.width - Self.titleHorizontalPadding), + width: rect.width, height: textHeight ) title.draw(in: drawRect) diff --git a/TableProTests/Views/Results/SortableHeaderCellTests.swift b/TableProTests/Views/Results/SortableHeaderCellTests.swift new file mode 100644 index 000000000..00972b872 --- /dev/null +++ b/TableProTests/Views/Results/SortableHeaderCellTests.swift @@ -0,0 +1,25 @@ +import AppKit +@testable import TablePro +import Testing + +@MainActor +@Suite("SortableHeaderCell") +struct SortableHeaderCellTests { + @Test("Title rect uses data cell horizontal padding") + func titleRectUsesDataCellHorizontalPadding() { + let cell = SortableHeaderCell(textCell: "id") + let titleRect = cell.titleRect(forBounds: NSRect(x: 10, y: 0, width: 100, height: 24)) + + #expect(titleRect.minX == 14) + #expect(titleRect.width == 92) + } + + @Test("Narrow title rect does not produce negative width") + func narrowTitleRectDoesNotProduceNegativeWidth() { + let cell = SortableHeaderCell(textCell: "id") + let titleRect = cell.titleRect(forBounds: NSRect(x: 0, y: 0, width: 6, height: 24)) + + #expect(titleRect.minX == 3) + #expect(titleRect.width == 0) + } +}