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
17 changes: 16 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Copy To across database engines, with every type approximation listed before the copy runs. (#1491)
- Per-table `WHERE` and row limit in Copy To. (#1491)
- Server-side `INSERT … SELECT` when a copy's two sides are one connection. (#1491)
- Tunnel Command pane, with presets for `kubectl port-forward` and `aws ssm start-session` and a custom command line. (#2520)
- Tunnel Command transport, with presets for `kubectl port-forward` and `aws ssm start-session` and a custom command line. (#2520)
- Bar chart column in the EXPLAIN tree, with a Metric menu for self cost, self time and row counts. (#2633)
- Database type change from inside the connection editor.
- The reason Save is unavailable, next to the Save button in the connection editor.
- Approval setting for MCP connection access, with the list of approved connections and a Forget action. (#2640)

### Changed

- Connection editor rebuilt around a sidebar of four sections, General, Network, Options and Appearance, in place of up to eleven panes.
- One Connect via picker for SSH, Cloudflare, Cloud SQL Auth Proxy, SOCKS and Tunnel Command, in place of five Enable switches.
- Save, Cancel and Test Connection on a bottom action bar instead of the titlebar.
- `Use ~/.pgpass` below Username rather than above it.
- Tab moves focus out of Startup Commands and Pre-Connect Script instead of inserting a tab.

### Fixed

- Connection with two transports enabled reaching the database directly, with neither transport applied.
- Delete Connection missing from the connection editor since 0.39.0.
- Continue dimmed after filtering the database chooser down to one driver.
- Down arrow not reaching the list from the database chooser's search field.
- VoiceOver reading a database chooser row's icon before the driver's name.
- Animations that played through the Reduce Motion setting when removing a jump host or copying DDL or a query plan.
- Icon-only controls with no VoiceOver name or tooltip in the date picker, row inspector and slash command settings.
- `is_connected` reported as true over MCP for a connection that had stopped answering.
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ To ship one: add the record type or field in CloudKit Console (or `xcrun cktool

**The data grid's column window measures the viewport against the whole column run, so its rebase counts chrome only**: `NSTableColumn.isHidden` costs O(attached columns) per write, because AppKit walks every row view and re-sorts its subviews to rebuild the key view loop. Hiding the columns outside a viewport is therefore quadratic in the column count: measured at 233ms for 100 columns, 8.1s for 500 and 34s for 1000, with the relayout debris of those writes never reclaimed, which is where a 500-column table's 837MB went. No AppKit knob helps: `autorecalculatesKeyViewLoop = false`, `beginUpdates`/`endUpdates` and hiding before any row exists were all measured and none of them changed it. The grid therefore keeps every column attached and visible, and pays nothing for the ones off screen by not building a view for them: `tableView(_:viewFor:row:)` returns nil for every data column and `DataGridRowView` draws the cells the viewport touches with CoreText. Measured on a 500-column table: opening it went from 12.4s to 26ms and from 837MB to 3.9MB, and the whole table holds 26 views rather than 12,500. Never reintroduce a column window built on `isHidden`.

**A drawn cell can only speak through a mounted view, so accessibility changes the grid's shape**: `NSTableView` builds its `AXCell` tree from cell views and from nothing else. An `NSAccessibilityElement` published by the row never reaches that tree, whichever attribute carries the text, whether the attribute is set or overridden, and however the element is parented; AppKit puts its own placeholder there instead, so the grid publishes a full set of correctly placed, permanently blank cells and reads as empty. That shipped as the drawn-cell rewrite's accessibility (#2381), where every value in every result was silent to VoiceOver. `DataGridCellAccessibilityView` is the answer: one view per data cell that draws nothing, takes no clicks (`hitTest` returns nil) and reads its text through the coordinator, so an edit is spoken with nothing to invalidate. It is mounted only once `DataGridAccessibility.isActive`, which the first accessibility question asked of the grid sets, because nothing reports an attached client: `NSWorkspace.isVoiceOverEnabled` covers VoiceOver alone and nothing at all reports Switch Control, Voice Control, an inspector or XCUITest. And it is mounted only for the rows in the viewport: walking the tree makes `NSTableView` prepare every row of the page, not the twenty-five on screen, and ask for a view for each one, so mounting on preparation alone put 9,000 views and a 21,000 element tree behind a 1,000-row page and starved the app of the main thread badly enough that a sample database never finished opening. A row prepared while it was off screen is answered with no view and is never asked again, so `remountAccessibilityCells()` reloads the rows a scroll reveals; it runs off the clip view's `boundsDidChangeNotification` rather than the scroll view's live-scroll pair, because that is the only one that also reports the programmatic `scrollRowToVisible` VoiceOver uses to reach an off-screen row. Activation remounts every grid, deferred off the query that raised it so the remount cannot re-enter the tree AppKit is walking. Two consequences for tests. A row is as wide as the grid however narrow the result is, so its centre is empty width no cell covers; and a table publishes its columns as siblings of its rows, each as tall as every row it spans and later in the tree, so XCUITest reads every row and every cell in the grid as obscured and refuses to click either. A UI test clicks a point offset from the `data-grid` element, never a row or cell element.
**A drawn cell can only speak through a mounted view, so accessibility changes the grid's shape**: `NSTableView` builds its `NSAccessibility.Role.cell` tree from cell views and from nothing else. An `NSAccessibilityElement` published by the row never reaches that tree, whichever attribute carries the text, whether the attribute is set or overridden, and however the element is parented; AppKit puts its own placeholder there instead, so the grid publishes a full set of correctly placed, permanently blank cells and reads as empty. That shipped as the drawn-cell rewrite's accessibility (#2381), where every value in every result was silent to VoiceOver. `DataGridCellAccessibilityView` is the answer: one view per data cell that draws nothing, takes no clicks (`hitTest` returns nil) and reads its text through the coordinator, so an edit is spoken with nothing to invalidate. It is mounted only once `DataGridAccessibility.isActive`, which the first accessibility question asked of the grid sets, because nothing reports an attached client: `NSWorkspace.isVoiceOverEnabled` covers VoiceOver alone and nothing at all reports Switch Control, Voice Control, an inspector or XCUITest. And it is mounted only for the rows in the viewport: walking the tree makes `NSTableView` prepare every row of the page, not the twenty-five on screen, and ask for a view for each one, so mounting on preparation alone put 9,000 views and a 21,000 element tree behind a 1,000-row page and starved the app of the main thread badly enough that a sample database never finished opening. A row prepared while it was off screen is answered with no view and is never asked again, so `remountAccessibilityCells()` reloads the rows a scroll reveals; it runs off the clip view's `boundsDidChangeNotification` rather than the scroll view's live-scroll pair, because that is the only one that also reports the programmatic `scrollRowToVisible` VoiceOver uses to reach an off-screen row. Activation remounts every grid, deferred off the query that raised it so the remount cannot re-enter the tree AppKit is walking. Two consequences for tests. A row is as wide as the grid however narrow the result is, so its centre is empty width no cell covers; and a table publishes its columns as siblings of its rows, each as tall as every row it spans and later in the tree, so XCUITest reads every row and every cell in the grid as obscured and refuses to click either. A UI test clicks a point offset from the `data-grid` element, never a row or cell element.

**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).

Expand Down
54 changes: 36 additions & 18 deletions TablePro/Core/Plugins/ConnectionField+AuthFieldOrder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,30 +6,48 @@
import TableProPluginKit

extension Collection where Element == ConnectionField {
/// Fields that decide whether the built-in Username and Password appear: either they carry the
/// flag themselves (an auth-method dropdown, a password-file toggle), or they gate a dependent
/// field that carries it (SQL Server's Kerberos principal, Snowflake's OAuth token).
var credentialControllerIds: Set<String> {
Set(
filter { $0.hidesUsername || $0.hidesPassword }
.map { $0.visibleWhen?.fieldId ?? $0.id }
)
/// Fields that decide whether the built-in Username and Password appear, mapped to whether
/// Username is among what they hide.
///
/// A field is a controller either by carrying the flag itself (an auth-method dropdown, a
/// password-file toggle) or by gating a dependent field that carries it (SQL Server's Kerberos
/// principal, Snowflake's OAuth token).
var credentialControllerRoles: [String: Bool] {
var roles: [String: Bool] = [:]
for field in self where field.hidesUsername || field.hidesPassword {
let controllerId = field.visibleWhen?.fieldId ?? field.id
roles[controllerId] = (roles[controllerId] ?? false) || field.hidesUsername
}
return roles
}

/// Splits the fields so the credential controllers render above the built-in Username and
/// Password. A controller placed below them shifts position every time its own selection shows
/// or hides those credentials.
func splitCredentialControllers() -> (controllers: [ConnectionField], rest: [ConnectionField]) {
let controllerIds = credentialControllerIds
var controllers: [ConnectionField] = []
/// Splits the fields so every credential controller renders above what it controls, and no
/// further up than that.
///
/// A controller below its dependents shifts position every time its own selection shows or
/// hides them, which is why they are lifted at all. Lifting a password-only controller above
/// Username as well is the other error: `usePgpass` is a toggle about the password, and it
/// pushed the Username field third in PostgreSQL's Authentication section.
func splitCredentialControllers() -> (
usernameControllers: [ConnectionField],
passwordControllers: [ConnectionField],
rest: [ConnectionField]
) {
let roles = credentialControllerRoles
var usernameControllers: [ConnectionField] = []
var passwordControllers: [ConnectionField] = []
var rest: [ConnectionField] = []
for field in self {
if controllerIds.contains(field.id) {
controllers.append(field)
} else {
guard let hidesUsername = roles[field.id] else {
rest.append(field)
continue
}
if hidesUsername {
usernameControllers.append(field)
} else {
passwordControllers.append(field)
}
}
return (controllers, rest)
return (usernameControllers, passwordControllers, rest)
}
}
35 changes: 35 additions & 0 deletions TablePro/Models/Connection/ConnectionTunnelKind.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,41 @@ enum ConnectionTunnelKind: String, CaseIterable, Sendable {
case .remoteFile: return String(localized: "Remote Database File")
}
}

/// One line saying what this transport does, shown under the connection form's picker so the
/// choice can be made without opening the documentation.
///
/// Plain prose, no backticks: the picker's footer renders these through `Text(String)`, which
/// takes the verbatim initializer and would print the backticks as characters.
var summary: String {
switch self {
case .ssh:
return String(localized: "Forwards a local port to the database through an SSH server.")
case .cloudflare:
return String(localized: "Runs cloudflared against a Cloudflare Access application.")
case .cloudSQLProxy:
return String(localized: "Runs the Google Cloud SQL Auth Proxy against an instance connection name.")
case .socksProxy:
return String(localized: "Routes through a SOCKS5 proxy, which also resolves the database hostname.")
case .tunnelCommand:
return String(localized: "Holds a command that forwards a local port, such as kubectl port-forward.")
case .remoteFile:
return String(localized: "Copies a database file from an SSH server and opens the copy read-only.")
}
}

/// The connection form's label for reaching the database with no transport in between.
static var directDisplayName: String {
String(localized: "Direct")
}

/// A file-based driver reaches its database through a path, not a host and a port, and it is
/// exactly the driver that shows this picker in order to offer Remote Database File.
static func directSummary(isFileBased: Bool) -> String {
isFileBased
? String(localized: "Opens the database file on this Mac.")
: String(localized: "Connects straight to the host and port on the General tab.")
}
}

extension DatabaseConnection {
Expand Down
Loading
Loading