diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a51413ad0..4d6ed162a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index af9fdea486..cac2ed21af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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). diff --git a/TablePro/Core/Plugins/ConnectionField+AuthFieldOrder.swift b/TablePro/Core/Plugins/ConnectionField+AuthFieldOrder.swift index 294985b16d..79892f7983 100644 --- a/TablePro/Core/Plugins/ConnectionField+AuthFieldOrder.swift +++ b/TablePro/Core/Plugins/ConnectionField+AuthFieldOrder.swift @@ -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 { - 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) } } diff --git a/TablePro/Models/Connection/ConnectionTunnelKind.swift b/TablePro/Models/Connection/ConnectionTunnelKind.swift index fc378c8006..1e98875725 100644 --- a/TablePro/Models/Connection/ConnectionTunnelKind.swift +++ b/TablePro/Models/Connection/ConnectionTunnelKind.swift @@ -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 { diff --git a/TablePro/Views/Connection/ConnectionAdvancedView.swift b/TablePro/Views/Connection/ConnectionAdvancedView.swift deleted file mode 100644 index e1ca1f7f22..0000000000 --- a/TablePro/Views/Connection/ConnectionAdvancedView.swift +++ /dev/null @@ -1,135 +0,0 @@ -// -// ConnectionAdvancedView.swift -// TablePro -// -// Created by Ngo Quoc Dat on 31/3/26. -// - -import AppKit -import SwiftUI -import TableProPluginKit - -struct ConnectionAdvancedView: View { - @Binding var additionalFieldValues: [String: String] - @Binding var startupCommands: String - @Binding var preConnectScript: String - @Binding var aiPolicy: AIConnectionPolicy? - @Binding var externalAccess: ExternalAccessLevel - @Binding var localOnly: Bool - - let databaseType: DatabaseType - let additionalConnectionFields: [ConnectionField] - /// Values from every pane, not just this one, so a rule can point at a field in another - /// section. The Redis Database Index field is hidden by the Connection Mode field, which the - /// Connection pane owns. - let visibilityValues: [String: String] - - var body: some View { - Form { - let advancedFields = additionalConnectionFields.filter { $0.section == .advanced } - if !advancedFields.isEmpty { - Section(databaseType.displayName) { - ForEach(advancedFields, id: \.id) { field in - if isFieldVisible(field) { - ConnectionFieldRow( - field: field, - value: Binding( - get: { - additionalFieldValues[field.id] - ?? field.defaultValue ?? "" - }, - set: { additionalFieldValues[field.id] = $0 } - ) - ) - } - } - } - } - - Section { - StartupCommandsEditor(text: $startupCommands) - .frame(height: 80) - } header: { - Text(String(localized: "Startup Commands")) - } footer: { - Text("SQL commands to run after connecting, e.g. SET time_zone = 'Asia/Ho_Chi_Minh'. One per line or separated by semicolons.") - .font(.caption) - .foregroundStyle(.secondary) - } - - Section { - StartupCommandsEditor(text: $preConnectScript) - .frame(height: 80) - } header: { - Text(String(localized: "Pre-Connect Script")) - } footer: { - Text("Shell script to run before connecting. Non-zero exit aborts connection.") - .font(.caption) - .foregroundStyle(.secondary) - } - - Section { - if AppSettingsManager.shared.ai.enabled { - Picker(String(localized: "AI Policy"), selection: $aiPolicy) { - Text(String(localized: "Use Default")) - .tag(AIConnectionPolicy?.none as AIConnectionPolicy?) - ForEach(AIConnectionPolicy.allCases) { policy in - Text(policy.displayName) - .tag(AIConnectionPolicy?.some(policy) as AIConnectionPolicy?) - } - } - } - - Picker(String(localized: "External Clients"), selection: $externalAccess) { - ForEach(ExternalAccessLevel.allCases) { level in - Text(level.displayName).tag(level) - } - } - .pickerStyle(.segmented) - } header: { - Text(String(localized: "External Access")) - } footer: { - VStack(alignment: .leading, spacing: 4) { - if AppSettingsManager.shared.ai.enabled { - // swiftlint:disable:next line_length - Text(String(localized: "AI Policy controls in-app AI agents. External Clients controls Raycast, Cursor, Claude Desktop, other MCP clients, and AppleScript. Effective scope is the minimum of the requesting token's scope and the External Clients level.")) - } else { - // swiftlint:disable:next line_length - Text(String(localized: "Controls how external clients (Raycast, Cursor, Claude Desktop, AppleScript) access this connection. Tokens cannot exceed this level even with full-access scope.")) - } - } - .font(.caption) - .foregroundStyle(.secondary) - } - - if AppSettingsManager.shared.sync.enabled { - Section(String(localized: "iCloud Sync")) { - Toggle(String(localized: "Local only"), isOn: $localOnly) - Text("This connection won't sync to other devices via iCloud.") - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - .formStyle(.grouped) - .scrollContentBackground(.hidden) - } - - private func isFieldVisible(_ field: ConnectionField) -> Bool { - PluginFieldRendering.isFieldVisible(field, type: databaseType, values: visibilityValues) - } -} - -// MARK: - Startup Commands Editor - -struct StartupCommandsEditor: View { - @Binding var text: String - - var body: some View { - TextValueEditor( - text: $text, - font: .monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular), - borderType: .bezelBorder - ) - } -} diff --git a/TablePro/Views/Connection/TypeChooser/DatabaseTypeChooserModel.swift b/TablePro/Views/Connection/TypeChooser/DatabaseTypeChooserModel.swift index 87eea0928d..47ba40ea8a 100644 --- a/TablePro/Views/Connection/TypeChooser/DatabaseTypeChooserModel.swift +++ b/TablePro/Views/Connection/TypeChooser/DatabaseTypeChooserModel.swift @@ -9,7 +9,10 @@ import Observation @MainActor @Observable final class DatabaseTypeChooserModel { - var searchText: String = "" + var searchText: String = "" { + didSet { settleHighlight() } + } + var highlightedType: DatabaseType? private let allTypes: [DatabaseType] @@ -44,4 +47,55 @@ final class DatabaseTypeChooserModel { .map { (category: $0.key, types: $0.value.sorted { $0.rawValue < $1.rawValue }) } .sorted { $0.category.sortOrder < $1.category.sortOrder } } + + /// The rows as the list draws them, which is category order and then alphabetical, not the + /// order `filteredTypes` happens to produce. Arrowing has to follow what is on screen. + var orderedTypes: [DatabaseType] { + groupedTypes.flatMap(\.types) + } + + /// The match a query most plausibly meant, or nil when the query matched nothing. + /// + /// Ranked, and that is the whole point: the filter also matches taglines and category names, so + /// the first row on screen is routinely not the best answer. "PostgreSQL" matches CockroachDB + /// ("Distributed SQL, PostgreSQL-compatible") and PGlite, and CockroachDB sorts ahead of + /// PostgreSQL alphabetically inside Relational. Arming the first row would let Return commit a + /// driver the user never looked at, and on an existing connection a type change resets its + /// credentials, SSL, driver options and transport. + var bestMatch: DatabaseType? { + let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !trimmed.isEmpty else { return nil } + let candidates = orderedTypes + if let exact = candidates.first(where: { $0.rawValue.lowercased() == trimmed }) { + return exact + } + if let prefixed = candidates.first(where: { $0.rawValue.lowercased().hasPrefix(trimmed) }) { + return prefixed + } + if let named = candidates.first(where: { $0.rawValue.lowercased().contains(trimmed) }) { + return named + } + return candidates.count == 1 ? candidates.first : nil + } + + func moveHighlight(by offset: Int) { + let items = orderedTypes + guard !items.isEmpty else { return } + guard let current = highlightedType, let index = items.firstIndex(of: current) else { + highlightedType = offset > 0 ? items.first : items.last + return + } + let target = index + offset + guard items.indices.contains(target) else { return } + highlightedType = items[target] + } + + /// Keeps a highlight the query still shows, and otherwise arms the best match, or nothing. + /// + /// Never falls back to the first row: a query that matched only taglines leaves the highlight + /// nil and Continue dimmed, which is correct, because no row is a defensible default there. + private func settleHighlight() { + if let current = highlightedType, orderedTypes.contains(current) { return } + highlightedType = bestMatch + } } diff --git a/TablePro/Views/Connection/TypeChooser/DatabaseTypeChooserSheet.swift b/TablePro/Views/Connection/TypeChooser/DatabaseTypeChooserSheet.swift index 4942162fb3..78533a0ca6 100644 --- a/TablePro/Views/Connection/TypeChooser/DatabaseTypeChooserSheet.swift +++ b/TablePro/Views/Connection/TypeChooser/DatabaseTypeChooserSheet.swift @@ -52,8 +52,19 @@ struct DatabaseTypeChooserSheet: View { Spacer() - NativeSearchField(text: $model.searchText, placeholder: String(localized: "Search")) - .frame(width: 180) + /// Arrow keys are handed over by the search field's own `NSSearchFieldDelegate`, which + /// is the only route: a focused field editor consumes them as `doCommandBySelector:` + /// before `onMoveCommand` or `onKeyPress` can see them, and `searchFocused` is + /// macOS 15. `onSubmit` stays unwired, or Return would commit through both the field + /// editor and Continue's `.defaultAction`. + NativeSearchField( + text: $model.searchText, + placeholder: String(localized: "Search"), + onMoveUp: { model.moveHighlight(by: -1) }, + onMoveDown: { model.moveHighlight(by: 1) }, + accessibilityIdentifier: "database-type-chooser-search" + ) + .frame(width: 180) } .padding(20) } @@ -94,6 +105,10 @@ struct DatabaseTypeChooserSheet: View { proxy.scrollTo(initialType, anchor: .center) } } + .onChange(of: model.highlightedType) { _, highlighted in + guard let highlighted else { return } + proxy.scrollTo(highlighted) + } } } } @@ -142,10 +157,15 @@ private struct DatabaseTypeChooserRow: View { var body: some View { HStack(spacing: 12) { + /// The row's icon is decoration the name already carries, and left unhidden it + /// publishes its own element, so VoiceOver reads "Cylinder Shape, Filled" ahead of the + /// driver. `Image(decorative:)` cannot cover it, because `DatabaseType.iconImage` also + /// returns an SF Symbol. type.iconImage .renderingMode(.template) .foregroundStyle(type.themeColor) .frame(width: 26, height: 26) + .accessibilityHidden(true) VStack(alignment: .leading, spacing: 2) { Text(type.rawValue) diff --git a/TablePro/Views/ConnectionForm/Components/ConnectionFormActionBar.swift b/TablePro/Views/ConnectionForm/Components/ConnectionFormActionBar.swift new file mode 100644 index 0000000000..0c5248ba27 --- /dev/null +++ b/TablePro/Views/ConnectionForm/Components/ConnectionFormActionBar.swift @@ -0,0 +1,112 @@ +// +// ConnectionFormActionBar.swift +// TablePro +// + +import AppKit +import SwiftUI + +/// The window's commit actions, and the reason they are unavailable. +/// +/// macOS puts the buttons that commit an editor window at the bottom trailing edge rather than in +/// the titlebar, and it leaves the leading edge for the status the buttons depend on. That pairing +/// is the point here: a disabled Save is only actionable next to the field it is waiting for. +struct ConnectionFormActionBar: View { + @Bindable var coordinator: ConnectionFormCoordinator + + /// Walked once per body evaluation and read four times from it. + /// + /// Every read costs three `PluginManager.additionalConnectionFields` calls, several locked + /// `PluginMetadataRegistry` lookups and an `SSHConfiguration` allocation, and this view observes + /// the connection name, so it re-evaluates on every keystroke in the Name field. + var body: some View { + let issues = coordinator.validationIssues + let canCommit = issues.isEmpty && !coordinator.isInstallingPlugin + + return HStack(spacing: 12) { + deleteButton + validationMessage(issues) + Spacer(minLength: 12) + TestConnectionStatusButton(coordinator: coordinator) + Button(String(localized: "Cancel")) { + coordinator.cancel() + } + .keyboardShortcut(.cancelAction) + if coordinator.isNew { + Button(String(localized: "Save")) { + coordinator.save() + } + .disabled(!canCommit) + } + Button(defaultActionTitle) { + coordinator.commit() + } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + .disabled(!canCommit) + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + } + + /// Restores the Delete the editor shipped through 0.38.0. + /// + /// It sat at `ToolbarItemPlacement.destructiveAction` until #995 rebuilt the form and dropped + /// the button while porting its body into `ConnectionFormCoordinator.deleteCurrent()`, which + /// has had no caller since. The window has no toolbar now, so it takes the bottom bar's leading + /// edge, which is where `SSHProfileEditorView` already puts the same action. + @ViewBuilder + private var deleteButton: some View { + if !coordinator.isNew { + Button(String(localized: "Delete"), role: .destructive) { + confirmDelete() + } + .accessibilityIdentifier("connection-form-delete") + } + } + + private func confirmDelete() { + Task { + let confirmed = await AlertHelper.confirmDestructive( + title: String(localized: "Delete Connection"), + message: String(localized: "Are you sure you want to delete this connection? This cannot be undone."), + confirmButton: String(localized: "Delete"), + window: NSApp.keyWindow + ) + if confirmed { + coordinator.deleteCurrent() + } + } + } + + private var defaultActionTitle: String { + coordinator.isNew + ? String(localized: "Save & Connect") + : String(localized: "Save") + } + + /// Names the tab as well as the issue whenever the issue is on a tab the user is not looking + /// at, so the message says where to go and not only what is wrong. + @ViewBuilder + private func validationMessage(_ issues: [String]) -> some View { + if let first = issues.first { + Label( + messageText(first), + systemImage: "exclamationmark.triangle.fill" + ) + .font(.callout) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .help(issues.joined(separator: "\n")) + .accessibilityIdentifier("connection-form-validation") + } + } + + private func messageText(_ issue: String) -> String { + guard let tab = coordinator.firstTabWithIssue, tab != coordinator.selectedTab else { + return issue + } + return String(format: String(localized: "%1$@: %2$@"), tab.title, issue) + } +} diff --git a/TablePro/Views/ConnectionForm/Components/StartupCommandsEditor.swift b/TablePro/Views/ConnectionForm/Components/StartupCommandsEditor.swift new file mode 100644 index 0000000000..31584fe217 --- /dev/null +++ b/TablePro/Views/ConnectionForm/Components/StartupCommandsEditor.swift @@ -0,0 +1,26 @@ +// +// StartupCommandsEditor.swift +// TablePro +// + +import AppKit +import SwiftUI + +/// The multi-line text field the connection form uses for startup SQL, the pre-connect script and +/// the AI rules. +/// +/// `movesFocusOnTab` is what makes it a form field rather than a code editor: Tab leaves for the +/// next control instead of inserting a tab character, which is how macOS expects a text view +/// inside a form to behave. Startup Commands and Pre-Connect Script trapped Tab; AI Rules did not. +struct StartupCommandsEditor: View { + @Binding var text: String + + var body: some View { + TextValueEditor( + text: $text, + font: .monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular), + borderType: .bezelBorder, + movesFocusOnTab: true + ) + } +} diff --git a/TablePro/Views/ConnectionForm/Toolbar/TestConnectionStatusButton.swift b/TablePro/Views/ConnectionForm/Components/TestConnectionStatusButton.swift similarity index 100% rename from TablePro/Views/ConnectionForm/Toolbar/TestConnectionStatusButton.swift rename to TablePro/Views/ConnectionForm/Components/TestConnectionStatusButton.swift diff --git a/TablePro/Views/ConnectionForm/Components/TunnelExclusivityBanner.swift b/TablePro/Views/ConnectionForm/Components/TunnelExclusivityBanner.swift deleted file mode 100644 index ad73f89524..0000000000 --- a/TablePro/Views/ConnectionForm/Components/TunnelExclusivityBanner.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// TunnelExclusivityBanner.swift -// TablePro -// - -import SwiftUI - -struct TunnelExclusivityBanner: View { - let coordinator: ConnectionFormCoordinator - let currentKind: ConnectionTunnelKind - - var body: some View { - Section { - Label( - String( - format: String(localized: "A connection can use one connection method at a time. Disable the other methods to use %@."), - currentKind.displayName - ), - systemImage: "exclamationmark.triangle.fill" - ) - .foregroundStyle(.orange) - ForEach(coordinator.otherEnabledTunnels(excluding: currentKind)) { other in - Button(String(format: String(localized: "Disable %@"), other.kind.displayName)) { - other.disable() - } - } - } - } -} diff --git a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator+Transport.swift b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator+Transport.swift new file mode 100644 index 0000000000..18485073c4 --- /dev/null +++ b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator+Transport.swift @@ -0,0 +1,95 @@ +// +// ConnectionFormCoordinator+Transport.swift +// TablePro +// + +import Foundation + +/// The form's transport selection, in the same vocabulary the connect path reads. +/// +/// `DatabaseConnection.activeTunnelKind` answers `nil` whenever more than one transport is +/// enabled, and `DatabaseManager.activeTunnelManager` then opens a direct connection to the +/// database host, so a connection carrying two enabled transports silently bypasses both. The +/// form used to allow exactly that, warn about it in a banner, and leave Save enabled. Editing +/// one optional value instead of five booleans makes the state unrepresentable rather than +/// discouraged. +@MainActor +extension ConnectionFormCoordinator { + var transport: ConnectionTunnelKind? { + get { + if ssh.state.enabled { + return supportsRemoteDatabaseFile ? .remoteFile : .ssh + } + if cloudflareTunnel.state.enabled { return .cloudflare } + if cloudSQLProxy.state.enabled { return .cloudSQLProxy } + if socksProxy.state.enabled { return .socksProxy } + if tunnelCommand.state.enabled { return .tunnelCommand } + return nil + } + set { + let usesSSHServer = newValue == .ssh || newValue == .remoteFile + ssh.state.enabled = usesSSHServer + if newValue != .remoteFile { + ssh.state.remoteFilePath = "" + } + if newValue != .ssh { + /// Only a port forward has something to forward to, and the field lives in the SSH + /// sections, so a path left behind by another transport would be unreachable while + /// still dimming Host and Port and still being written by `network.write(into:)`. + network.sshForwardUnixSocketPath = "" + } + cloudflareTunnel.state.enabled = newValue == .cloudflare + cloudSQLProxy.state.enabled = newValue == .cloudSQLProxy + socksProxy.state.enabled = newValue == .socksProxy + tunnelCommand.state.enabled = newValue == .tunnelCommand + testSucceeded = false + } + } + + /// Direct first, then whatever this driver can reach a server through. + /// + /// `.ssh` and `.remoteFile` are the same SSH server carrying different cargo, and both are + /// stored in `ssh.state`, so only one of them can be offered: the getter decides which by + /// capability, and offering both would let the picker select `.ssh` and read back `.remoteFile`. + var availableTransports: [ConnectionTunnelKind?] { + var transports: [ConnectionTunnelKind?] = [nil] + if supportsRemoteDatabaseFile { + transports.append(.remoteFile) + } else if services.pluginManager.supportsSSH(for: network.type) { + transports.append(.ssh) + } + if services.pluginManager.supportsCloudflareTunnel(for: network.type) { + transports.append(.cloudflare) + } + if network.type.supportsCloudSQLProxy { + transports.append(.cloudSQLProxy) + } + if services.pluginManager.supportsSOCKSProxy(for: network.type) { + transports.append(.socksProxy) + } + if services.pluginManager.supportsTunnelCommand(for: network.type) { + transports.append(.tunnelCommand) + } + return transports + } + + var supportsRemoteDatabaseFile: Bool { + services.pluginManager.supportsRemoteDatabaseFile(for: network.type) + } + + var supportsSSL: Bool { + services.pluginManager.supportsSSL(for: network.type) + } + + /// Collapses whatever the stored connection carries onto a single transport. + /// + /// Assigning through the setter is what does the work: a connection saved by an older build + /// with two transports enabled keeps the first and loses the rest, which is a repair, because + /// the connect path was reaching that database directly. A transport the current type no + /// longer offers falls back to direct, or a MySQL connection changed to SQLite would keep a + /// Cloudflare tunnel nothing in the form can see or switch off. + func normalizeTransport() { + let current = transport + transport = availableTransports.contains(current) ? current : nil + } +} diff --git a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator+TunnelExclusivity.swift b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator+TunnelExclusivity.swift deleted file mode 100644 index 0e3ae82fe9..0000000000 --- a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator+TunnelExclusivity.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// ConnectionFormCoordinator+TunnelExclusivity.swift -// TablePro -// - -import Foundation - -@MainActor -extension ConnectionFormCoordinator { - struct EnabledTunnel: Identifiable { - let kind: ConnectionTunnelKind - let disable: () -> Void - - var id: String { kind.rawValue } - } - - var enabledTunnels: [EnabledTunnel] { - var tunnels: [EnabledTunnel] = [] - if ssh.state.enabled { - tunnels.append(EnabledTunnel(kind: .ssh) { [weak self] in self?.ssh.state.disable() }) - } - if cloudflareTunnel.state.enabled { - tunnels.append(EnabledTunnel(kind: .cloudflare) { [weak self] in - self?.cloudflareTunnel.state.enabled = false - }) - } - if cloudSQLProxy.state.enabled { - tunnels.append(EnabledTunnel(kind: .cloudSQLProxy) { [weak self] in - self?.cloudSQLProxy.state.enabled = false - }) - } - if socksProxy.state.enabled { - tunnels.append(EnabledTunnel(kind: .socksProxy) { [weak self] in - self?.socksProxy.state.enabled = false - }) - } - if tunnelCommand.state.enabled { - tunnels.append(EnabledTunnel(kind: .tunnelCommand) { [weak self] in - self?.tunnelCommand.state.enabled = false - }) - } - return tunnels - } - - func otherEnabledTunnels(excluding kind: ConnectionTunnelKind) -> [EnabledTunnel] { - enabledTunnels.filter { $0.kind != kind } - } -} diff --git a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift index dcc014ffd7..617c98be88 100644 --- a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift +++ b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift @@ -40,7 +40,7 @@ final class ConnectionFormCoordinator { var advanced: AdvancedPaneViewModel var aiRules: AIRulesPaneViewModel - var selectedPane: ConnectionFormPane = .general + var selectedTab: ConnectionFormTab = .general var hasLoadedData: Bool = false var isTesting: Bool = false @@ -57,6 +57,8 @@ final class ConnectionFormCoordinator { var clipboardCandidate: ParsedConnection? var clipboardBannerDismissed: Bool = false + var isChoosingType: Bool = false + private var temporaryTestIds: Set = [] @@ -66,47 +68,35 @@ final class ConnectionFormCoordinator { var isNew: Bool { connectionId == nil } - var visiblePanes: [ConnectionFormPane] { - var panes: [ConnectionFormPane] = [.general] - if services.pluginManager.supportsSSH(for: network.type) { - panes.append(.ssh) - } - if services.pluginManager.supportsRemoteDatabaseFile(for: network.type) { - panes.append(.remoteFile) - } - if services.pluginManager.supportsCloudflareTunnel(for: network.type) { - panes.append(.cloudflareTunnel) - } - if network.type.supportsCloudSQLProxy { - panes.append(.cloudSQLProxy) - } - if services.pluginManager.supportsSOCKSProxy(for: network.type) { - panes.append(.socksProxy) - } - if services.pluginManager.supportsTunnelCommand(for: network.type) { - panes.append(.tunnelCommand) - } - if services.pluginManager.supportsSSL(for: network.type) { - panes.append(.ssl) + /// Filtered from `allCases` rather than assembled by hand, so the compiler makes every case + /// answer the visibility question. A tab left out of a hand-written list would vanish from the + /// picker *and* have its issues dropped from `validationIssues`, which is a disabled Save with + /// nothing to fix. + var visibleTabs: [ConnectionFormTab] { + ConnectionFormTab.allCases.filter { isVisible($0) } + } + + private func isVisible(_ tab: ConnectionFormTab) -> Bool { + switch tab { + case .general, .options, .appearance: + return true + case .network: + return availableTransports.count > 1 || supportsSSL } - panes.append(.customization) - panes.append(.advanced) - panes.append(.aiRules) - return panes } - var isFormValid: Bool { - network.validationIssues.isEmpty - && auth.validationIssues.isEmpty - && ssh.validationIssues.isEmpty - && remoteFile.validationIssues.isEmpty - && cloudflareTunnel.validationIssues.isEmpty - && cloudSQLProxy.validationIssues.isEmpty - && socksProxy.validationIssues.isEmpty - && tunnelCommand.validationIssues.isEmpty - && ssl.validationIssues.isEmpty - && customization.validationIssues.isEmpty - && advanced.validationIssues.isEmpty + /// Every issue the form knows about, tab by tab, in tab order. + /// + /// `isFormValid` reads this rather than repeating the list, so a validation rule cannot + /// disable Save while no tab claims it and leave the user with nothing to fix. + var validationIssues: [String] { + visibleTabs.flatMap { $0.validationIssues(for: self) } + } + + var isFormValid: Bool { validationIssues.isEmpty } + + var firstTabWithIssue: ConnectionFormTab? { + visibleTabs.first { !$0.validationIssues(for: self).isEmpty } } private let pendingInitialType: DatabaseType? @@ -168,6 +158,11 @@ final class ConnectionFormCoordinator { if let parsed = pendingInitialParsedURL { applyParsed(parsed) } + + /// After the URL too, not only after a stored connection: a URL naming an SSH server for a + /// driver that cannot tunnel would otherwise leave a transport enabled that no tab offers + /// and no validation counts. + normalizeTransport() } // MARK: - Lifecycle @@ -196,6 +191,7 @@ final class ConnectionFormCoordinator { customization.load(from: existing) advanced.load(from: existing) aiRules.load(from: existing) + normalizeTransport() } hasLoadedData = true } @@ -214,6 +210,13 @@ final class ConnectionFormCoordinator { // MARK: - Type change + /// Retypes the connection in place. `NetworkPaneViewModel.setType` had no caller, so picking + /// the wrong database meant cancelling the window and starting the form again. + func changeType(to newType: DatabaseType) { + isChoosingType = false + network.setType(newType) + } + func didChangeType(_ newType: DatabaseType) { testSucceeded = false if hasLoadedData { @@ -221,8 +224,13 @@ final class ConnectionFormCoordinator { auth.resetForType(newType) advanced.resetForType(newType) } - if !visiblePanes.contains(selectedPane) { - selectedPane = .general + /// Not `normalizeTransport()`: which transports exist and what they mean both change with + /// the type, and keeping the selection would turn a MySQL port forward into SQLite's + /// read-only file copy without saying so. The fields each transport holds are kept, so + /// re-picking costs nothing. + transport = nil + if !visibleTabs.contains(selectedTab) { + selectedTab = .general } isInstallingPlugin = false pluginInstallError = nil @@ -241,7 +249,9 @@ final class ConnectionFormCoordinator { saveConnection(connect: false) } - func saveAndConnect() { + /// The window's default action. A new connection is opened once it is stored; an existing one + /// is only saved, because the window it is already open in is the one the user came from. + func commit() { saveConnection(connect: isNew) } @@ -302,6 +312,33 @@ final class ConnectionFormCoordinator { ) } + /// The secure fields of the type being saved **and** of the type the connection started as. + /// + /// Retyping used to be impossible, so a save only ever had one type's secure fields to write. + /// Now that Change… exists, a SQL Server connection retyped to MySQL would leave its Kerberos + /// password in the Keychain under the same connection id, invisible to the form that owns it. + /// This mirrors what `ownedAdditionalFieldIDs()` already does for the plain fields. + internal func secureFieldsOwnedByForm() -> [ConnectionField] { + Self.secureFieldsOwnedByForm( + currentType: network.type, + originalType: originalConnection?.type, + pluginManager: services.pluginManager + ) + } + + internal static func secureFieldsOwnedByForm( + currentType: DatabaseType, + originalType: DatabaseType?, + pluginManager: PluginManager + ) -> [ConnectionField] { + var fields = pluginManager.additionalConnectionFields(for: currentType).filter(\.isSecure) + guard let originalType, originalType != currentType else { return fields } + let known = Set(fields.map(\.id)) + fields += pluginManager.additionalConnectionFields(for: originalType) + .filter { $0.isSecure && !known.contains($0.id) } + return fields + } + private func ownedAdditionalFieldIDs() -> Set { var ids = ConnectionFormEdits.appManagedAdditionalFieldIDs for field in services.pluginManager.additionalConnectionFields(for: network.type) { @@ -329,9 +366,7 @@ final class ConnectionFormCoordinator { var edits = buildEdits() edits.additionalFields["promptForPassword"] = auth.effectivePromptForPassword ? "true" : nil - let secureFields = services.pluginManager.additionalConnectionFields(for: network.type) - .filter(\.isSecure) - for field in secureFields { + for field in secureFieldsOwnedByForm() { if let value = edits.additionalFields[field.id], !value.isEmpty { storage.savePluginSecureField(value, fieldId: field.id, for: finalId) } else { @@ -749,7 +784,9 @@ final class ConnectionFormCoordinator { ssl.mode = parsed.sslMode ?? parsed.type.defaultSSLMode if let sshHostValue = parsed.sshHost { - ssh.state.enabled = true + /// Through the transport setter rather than the flag, so a URL naming an SSH server + /// can never leave a second transport enabled beside it. + transport = .ssh ssh.state.host = sshHostValue ssh.state.port = parsed.sshPort.map(String.init) ?? "" ssh.state.username = parsed.sshUsername ?? "" diff --git a/TablePro/Views/ConnectionForm/ConnectionFormDetailView.swift b/TablePro/Views/ConnectionForm/ConnectionFormDetailView.swift new file mode 100644 index 0000000000..08bdb8451a --- /dev/null +++ b/TablePro/Views/ConnectionForm/ConnectionFormDetailView.swift @@ -0,0 +1,71 @@ +// +// ConnectionFormDetailView.swift +// TablePro +// + +import SwiftUI + +/// The split view's detail column: the selected section over the bar that commits the window. +/// +/// The bar sits inside this column rather than under both, because the window's +/// contentViewController has to be the split controller itself for `toggleSidebar(_:)` and +/// `.sidebarTrackingSeparator` to resolve. Wrapping the split view to span a bar across both +/// columns would take that away, and the sidebar has nothing to commit anyway. +struct ConnectionFormDetailView: View { + @Bindable var coordinator: ConnectionFormCoordinator + + var body: some View { + VStack(spacing: 0) { + selectedPane + .frame(maxWidth: .infinity, maxHeight: .infinity) + Divider() + ConnectionFormActionBar(coordinator: coordinator) + } + /// On the detail column rather than beside the diagnostic sheet: two `.sheet` modifiers on + /// one view resolve to a single presenter on macOS, so whichever lost would keep its + /// binding true with nothing on screen, and Change… would go dead. + .sheet(isPresented: $coordinator.isChoosingType) { + DatabaseTypeChooserSheet( + initialType: coordinator.network.type, + onSelected: { coordinator.changeType(to: $0) }, + onCancel: { coordinator.isChoosingType = false } + ) + } + .sheet(item: $coordinator.pluginDiagnostic) { item in + PluginDiagnosticSheet(item: item) { + coordinator.pluginDiagnostic = nil + } + } + .pluginInstallPrompt(connection: $coordinator.pluginInstallConnection) { connection in + coordinator.connectAfterInstall(connection) + } + .alert( + String(localized: "Save Failed"), + isPresented: Binding( + get: { coordinator.saveError != nil }, + set: { if !$0 { coordinator.saveError = nil } } + ), + presenting: coordinator.saveError + ) { _ in + Button(String(localized: "OK"), role: .cancel) { + coordinator.saveError = nil + } + } message: { error in + Text(error) + } + } + + @ViewBuilder + private var selectedPane: some View { + switch coordinator.selectedTab { + case .general: + GeneralPaneView(coordinator: coordinator) + case .network: + NetworkPaneView(coordinator: coordinator) + case .options: + OptionsPaneView(coordinator: coordinator) + case .appearance: + AppearancePaneView(coordinator: coordinator) + } + } +} diff --git a/TablePro/Views/ConnectionForm/ConnectionFormPane.swift b/TablePro/Views/ConnectionForm/ConnectionFormPane.swift deleted file mode 100644 index 65e3143241..0000000000 --- a/TablePro/Views/ConnectionForm/ConnectionFormPane.swift +++ /dev/null @@ -1,84 +0,0 @@ -// -// ConnectionFormPane.swift -// TablePro -// - -import Foundation - -enum ConnectionFormPane: String, CaseIterable, Identifiable, Hashable { - case general - case ssh - case remoteFile - case cloudflareTunnel - case cloudSQLProxy - case socksProxy - case tunnelCommand - case ssl - case customization - case advanced - case aiRules - - var id: String { rawValue } - - var title: String { - switch self { - case .general: return String(localized: "General") - case .ssh: return String(localized: "SSH Tunnel") - case .remoteFile: return String(localized: "Remote File") - case .cloudflareTunnel: return String(localized: "Cloudflare Tunnel") - case .cloudSQLProxy: return String(localized: "Cloud SQL Auth Proxy") - case .socksProxy: return String(localized: "SOCKS Proxy") - case .tunnelCommand: return String(localized: "Tunnel Command") - case .ssl: return String(localized: "SSL/TLS") - case .customization: return String(localized: "Customization") - case .advanced: return String(localized: "Advanced") - case .aiRules: return String(localized: "AI Rules") - } - } - - var systemImage: String { - switch self { - case .general: return "network" - case .ssh: return "lock.shield" - case .remoteFile: return "externaldrive.connected.to.line.below" - case .cloudflareTunnel: return "cloud" - case .cloudSQLProxy: return "cloud.fill" - case .socksProxy: return "arrow.triangle.swap" - case .tunnelCommand: return "terminal" - case .ssl: return "lock.fill" - case .customization: return "paintbrush" - case .advanced: return "gearshape.2" - case .aiRules: return "sparkles" - } - } - - @MainActor - func validationBadge(for coordinator: ConnectionFormCoordinator) -> String? { - let issues: [String] - switch self { - case .general: - issues = coordinator.network.validationIssues + coordinator.auth.validationIssues - case .ssh: - issues = coordinator.ssh.validationIssues - case .remoteFile: - issues = coordinator.remoteFile.validationIssues - case .cloudflareTunnel: - issues = coordinator.cloudflareTunnel.validationIssues - case .cloudSQLProxy: - issues = coordinator.cloudSQLProxy.validationIssues - case .socksProxy: - issues = coordinator.socksProxy.validationIssues - case .tunnelCommand: - issues = coordinator.tunnelCommand.validationIssues - case .ssl: - issues = coordinator.ssl.validationIssues - case .customization: - issues = coordinator.customization.validationIssues - case .advanced: - issues = coordinator.advanced.validationIssues - case .aiRules: - issues = [] - } - return issues.isEmpty ? nil : "exclamationmark.triangle.fill" - } -} diff --git a/TablePro/Views/ConnectionForm/ConnectionFormSplitViewController.swift b/TablePro/Views/ConnectionForm/ConnectionFormSplitViewController.swift new file mode 100644 index 0000000000..8d8a5d86d6 --- /dev/null +++ b/TablePro/Views/ConnectionForm/ConnectionFormSplitViewController.swift @@ -0,0 +1,115 @@ +// +// ConnectionFormSplitViewController.swift +// TablePro +// +// NSSplitViewController rather than NavigationSplitView, for the same reason +// MainSplitViewController is one: only AppKit vends a real sidebar split item, and only a +// window whose contentViewController IS the split controller gets `toggleSidebar(_:)` and +// `.sidebarTrackingSeparator` through the responder chain. +// + +import AppKit +import Observation +import SwiftUI + +@MainActor +internal final class ConnectionFormSplitViewController: NSSplitViewController { + private static let sidebarMinThickness: CGFloat = 200 + private static let sidebarMaxThickness: CGFloat = 260 + private static let detailMinThickness: CGFloat = 480 + + private let coordinator: ConnectionFormCoordinator + + internal init(coordinator: ConnectionFormCoordinator) { + self.coordinator = coordinator + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + internal required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override internal func viewDidLoad() { + super.viewDidLoad() + splitView.isVertical = true + + /// `sidebarWithViewController:` is what makes the pane an actual sidebar: full window + /// height behind the titlebar, the vibrant material, and the divider a tracking separator + /// can align to. A plain split item gets none of it. + let sidebar = NSHostingController(rootView: ConnectionFormSidebar(coordinator: coordinator)) + sidebar.sizingOptions = [] + let sidebarItem = NSSplitViewItem(sidebarWithViewController: sidebar) + sidebarItem.canCollapse = true + sidebarItem.minimumThickness = Self.sidebarMinThickness + sidebarItem.maximumThickness = Self.sidebarMaxThickness + addSplitViewItem(sidebarItem) + + let detail = NSHostingController(rootView: ConnectionFormDetailView(coordinator: coordinator)) + detail.sizingOptions = [] + let detailItem = NSSplitViewItem(viewController: detail) + detailItem.minimumThickness = Self.detailMinThickness + /// Below `dragThatCannotResizeWindow` (490), or the pane's own width constraint outranks a + /// divider drag and the divider cannot move at all. + detailItem.holdingPriority = .defaultLow + addSplitViewItem(detailItem) + + trackTitle() + } + + /// The window title follows the connection's type, which the Change… button can now alter. + /// + /// `NSWindow(contentViewController:)` binds the window's title to this controller's, so nothing + /// writes `window.title` directly. `withObservationTracking` fires once per change, so the + /// closure re-arms itself. + private func trackTitle() { + withObservationTracking { + title = windowTitle + } onChange: { [weak self] in + Task { @MainActor in self?.trackTitle() } + } + } + + private var windowTitle: String { + coordinator.isNew + ? String(format: String(localized: "New %@ Connection"), coordinator.network.type.rawValue) + : String(format: String(localized: "Edit %@ Connection"), coordinator.network.type.rawValue) + } +} + +// MARK: - Toolbar + +/// The tracking separator alone: it keeps the titlebar's own divider on the split divider as that +/// divider is dragged, which is what makes the titlebar read as part of a sidebar window. +/// +/// No `.toggleSidebar`. The four sections are the window's only navigation, so a button whose job is +/// to hide them earns nothing in the titlebar. Collapsing stays reachable, because +/// `NSSplitViewController.toggleSidebar(_:)` is on the responder chain and the View menu's Show +/// Sidebar sends exactly that, so a sidebar dragged shut can always be brought back. +/// +/// `.sidebarTrackingSeparator` is supplied by AppKit whenever the window's contentViewController is +/// an `NSSplitViewController`, which is why this window has no wrapper around it. +@MainActor +internal final class ConnectionFormToolbarDelegate: NSObject, NSToolbarDelegate { + internal static let identifier = NSToolbar.Identifier("com.TablePro.toolbar.connectionForm") + + private static let items: [NSToolbarItem.Identifier] = [ + .sidebarTrackingSeparator, + ] + + internal func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + Self.items + } + + internal func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + Self.items + } + + internal func toolbar( + _ toolbar: NSToolbar, + itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, + willBeInsertedIntoToolbar flag: Bool + ) -> NSToolbarItem? { + nil + } +} diff --git a/TablePro/Views/ConnectionForm/ConnectionFormTab.swift b/TablePro/Views/ConnectionForm/ConnectionFormTab.swift new file mode 100644 index 0000000000..83a611a708 --- /dev/null +++ b/TablePro/Views/ConnectionForm/ConnectionFormTab.swift @@ -0,0 +1,72 @@ +// +// ConnectionFormTab.swift +// TablePro +// + +import Foundation + +/// The facets of one connection. +/// +/// A tab bar rather than a sidebar because a connection is a single object: a sidebar is how +/// macOS navigates between peer items, and there is only ever one item here. The set is fixed +/// except for Network, so the form does not change shape between database types. +enum ConnectionFormTab: String, CaseIterable, Identifiable, Hashable { + case general + case network + case options + case appearance + + var id: String { rawValue } + + var title: String { + switch self { + case .general: return String(localized: "General") + case .network: return String(localized: "Network") + case .options: return String(localized: "Options") + case .appearance: return String(localized: "Appearance") + } + } + + /// One glyph per section, all four distinct at a glance. + /// + /// The eleven-pane sidebar this replaced ran two clouds and two locks against each other, so + /// the icons said less than the labels did. + var systemImage: String { + switch self { + case .general: return "info.circle" + case .network: return "network" + case .options: return "slider.horizontal.3" + case .appearance: return "paintpalette" + } + } + + /// What is stopping this tab from being savable, in the words the user needs to fix it. + /// + /// These strings were computed and thrown away before: the form showed a warning triangle + /// and a disabled Save and never said which field was empty. + @MainActor + func validationIssues(for coordinator: ConnectionFormCoordinator) -> [String] { + switch self { + case .general: + return coordinator.network.validationIssues + coordinator.auth.validationIssues + case .network: + let groups: [[String]] = [ + coordinator.ssh.validationIssues, + coordinator.remoteFile.validationIssues, + coordinator.cloudflareTunnel.validationIssues, + coordinator.cloudSQLProxy.validationIssues, + coordinator.socksProxy.validationIssues, + coordinator.tunnelCommand.validationIssues, + coordinator.ssl.validationIssues + ] + return groups.flatMap { $0 } + case .options: + /// `customization` is claimed here rather than by Appearance because Safe Mode is the + /// only control it owns that could ever fail a rule, and Safe Mode renders on this tab. + /// Claiming it by view model instead would send the user to a tab with no such control. + return coordinator.advanced.validationIssues + coordinator.customization.validationIssues + case .appearance: + return [] + } + } +} diff --git a/TablePro/Views/ConnectionForm/ConnectionFormView.swift b/TablePro/Views/ConnectionForm/ConnectionFormView.swift deleted file mode 100644 index d1451b6177..0000000000 --- a/TablePro/Views/ConnectionForm/ConnectionFormView.swift +++ /dev/null @@ -1,121 +0,0 @@ -// -// ConnectionFormView.swift -// TablePro -// - -import SwiftUI -import TableProPluginKit - -struct ConnectionFormView: View { - let request: ConnectionFormRequest? - let close: () -> Void - - @State private var coordinator: ConnectionFormCoordinator? - - var body: some View { - Group { - if let coordinator { - ConnectionFormContent(coordinator: coordinator) - } else { - Color.clear - .frame(minWidth: 720, minHeight: 560) - } - } - .task(id: request) { - guard coordinator == nil else { return } - let draft = consumeDraft() - let new = ConnectionFormCoordinator( - connectionId: request?.editedConnectionId, - initialType: draft?.type, - initialParsedURL: draft?.parsedURL - ) - new.dismissAction = close - new.start() - new.detectClipboardConnectionStringIfNeeded() - coordinator = new - } - } - - private func consumeDraft() -> ConnectionFormDraft? { - guard let draftId = request?.draftId else { return nil } - return ConnectionFormDraftStore.shared.consume(draftId) - } -} - -private struct ConnectionFormContent: View { - @Bindable var coordinator: ConnectionFormCoordinator - - var body: some View { - NavigationSplitView { - ConnectionFormSidebar(coordinator: coordinator) - } detail: { - ConnectionFormDetail(coordinator: coordinator) - } - .frame(minWidth: 720, idealWidth: 820) - .frame(minHeight: 560, idealHeight: 600) - .navigationTitle( - coordinator.isNew - ? String(format: String(localized: "New %@ Connection"), coordinator.network.type.rawValue) - : String(format: String(localized: "Edit %@ Connection"), coordinator.network.type.rawValue) - ) - .toolbar { - ConnectionFormToolbar(coordinator: coordinator) - } - .sheet(item: $coordinator.pluginDiagnostic) { item in - PluginDiagnosticSheet(item: item) { - coordinator.pluginDiagnostic = nil - } - } - .pluginInstallPrompt(connection: $coordinator.pluginInstallConnection) { connection in - coordinator.connectAfterInstall(connection) - } - .alert( - String(localized: "Save Failed"), - isPresented: Binding( - get: { coordinator.saveError != nil }, - set: { if !$0 { coordinator.saveError = nil } } - ), - presenting: coordinator.saveError - ) { _ in - Button(String(localized: "OK"), role: .cancel) { - coordinator.saveError = nil - } - } message: { error in - Text(error) - } - } -} - -private struct ConnectionFormDetail: View { - @Bindable var coordinator: ConnectionFormCoordinator - - var body: some View { - Group { - switch coordinator.selectedPane { - case .general: - GeneralPaneView(coordinator: coordinator) - case .ssh: - SSHPaneView(coordinator: coordinator) - case .remoteFile: - RemoteFilePaneView(coordinator: coordinator) - case .cloudflareTunnel: - CloudflareTunnelPaneView(coordinator: coordinator) - case .cloudSQLProxy: - CloudSQLProxyPaneView(coordinator: coordinator) - case .socksProxy: - SOCKSProxyPaneView(coordinator: coordinator) - case .tunnelCommand: - TunnelCommandPaneView(coordinator: coordinator) - case .ssl: - SSLPaneView(coordinator: coordinator) - case .customization: - CustomizationPaneView(coordinator: coordinator) - case .advanced: - AdvancedPaneView(coordinator: coordinator) - case .aiRules: - AIRulesPaneView(coordinator: coordinator) - } - } - .navigationSplitViewColumnWidth(min: 480, ideal: 580) - } -} diff --git a/TablePro/Views/ConnectionForm/ConnectionFormWindowController.swift b/TablePro/Views/ConnectionForm/ConnectionFormWindowController.swift index 0cf51cccea..ce1a8f0b02 100644 --- a/TablePro/Views/ConnectionForm/ConnectionFormWindowController.swift +++ b/TablePro/Views/ConnectionForm/ConnectionFormWindowController.swift @@ -6,10 +6,11 @@ import AppKit import SwiftUI -/// Hosts `ConnectionFormView` in an AppKit window so the app no longer needs a SwiftUI -/// scene for it. The registry keyed by request is the behaviour `WindowGroup(for:)` gave -/// for free: two windows on the same request would each own a `ConnectionFormCoordinator` -/// writing the same stored connection, so a repeat open focuses the window that exists. +/// Hosts the connection editor in an AppKit window so the app needs no SwiftUI scene for it. +/// +/// The registry keyed by request is the behaviour `WindowGroup(for:)` gave for free: two windows on +/// the same request would each own a `ConnectionFormCoordinator` writing the same stored +/// connection, so a repeat open focuses the window that exists. @MainActor internal final class ConnectionFormWindowController: NSWindowController, NSWindowDelegate { private static var controllers: [ConnectionFormRequest: ConnectionFormWindowController] = [:] @@ -26,36 +27,63 @@ internal final class ConnectionFormWindowController: NSWindowController, NSWindo controllers[request]?.close() } - private static func initialTitle(for request: ConnectionFormRequest) -> String { - switch request { - case .edit: return String(localized: "Edit Connection") - case .create: return String(localized: "New Connection") - } - } + /// Held for the window's lifetime: the toolbar keeps only a weak delegate reference. + private let toolbarDelegate = ConnectionFormToolbarDelegate() private convenience init(request: ConnectionFormRequest) { + /// Built here rather than inside a SwiftUI `.task`, because the sidebar and the detail + /// column are now two separate hosting controllers and both edit the same coordinator. + /// Read once: `consume` removes the draft, so a second call returns nil and the parsed URL + /// would be silently dropped. + let draft = Self.draft(for: request) + let coordinator = ConnectionFormCoordinator( + connectionId: request.editedConnectionId, + initialType: draft?.type, + initialParsedURL: draft?.parsedURL + ) /// `dismiss()` is inert in a view hosted as a window's content view controller, so the /// form's save, cancel and delete paths need an explicit way to close the window. - let content = ConnectionFormView( - request: request, - close: { ConnectionFormWindowController.close(request) } - ) - .environment(\.appServices, .live) - let hosting = NSHostingController(rootView: content) - /// A standalone window wants the content's minimum to become the window's, unlike a - /// split pane's host, where the same minimum would pin the window's dividers. - hosting.sizingOptions = [.minSize] + coordinator.dismissAction = { ConnectionFormWindowController.close(request) } + coordinator.start() + coordinator.detectClipboardConnectionStringIfNeeded() + + let split = ConnectionFormSplitViewController(coordinator: coordinator) - let window = NSWindow.titled(Self.initialTitle(for: request), contentViewController: hosting) + /// The split controller IS the content view controller, with nothing wrapped around it. + /// That is what puts `toggleSidebar(_:)` on the responder chain for the View menu and lets + /// AppKit supply `.sidebarTrackingSeparator`; a container in between takes both away. + /// No `window.title` here: `NSWindow(contentViewController:)` binds the title to the + /// controller's own, which tracks the connection's type, so writing it once would pin the + /// generic string and the type would never reach the titlebar. + let window = NSWindow(contentViewController: split) window.identifier = NSUserInterfaceItemIdentifier(WindowIdentifier.connectionForm) - window.styleMask = [.titled, .closable, .miniaturizable, .resizable] + /// `.fullSizeContentView` with a transparent titlebar is what lets the sidebar run the + /// window's full height and carry the traffic lights, the way System Settings and this + /// app's own main window do. Without it the titlebar is an opaque band across the top and + /// the sidebar starts underneath it, which is the part that reads as a custom control. + window.styleMask = [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView] + window.titlebarAppearsTransparent = true + window.toolbarStyle = .unified window.isRestorable = false - window.setContentSize(NSSize(width: 820, height: 600)) + window.contentMinSize = NSSize(width: 720, height: 560) + window.setContentSize(NSSize(width: 820, height: 620)) window.setFrameAutosaveName(WindowIdentifier.connectionForm) + self.init(window: window) + + let toolbar = NSToolbar(identifier: ConnectionFormToolbarDelegate.identifier) + toolbar.delegate = toolbarDelegate + toolbar.displayMode = .iconOnly + toolbar.allowsUserCustomization = false + window.toolbar = toolbar window.delegate = self } + private static func draft(for request: ConnectionFormRequest) -> ConnectionFormDraft? { + guard let draftId = request.draftId else { return nil } + return ConnectionFormDraftStore.shared.consume(draftId) + } + internal func windowWillClose(_ notification: Notification) { Self.controllers = Self.controllers.filter { $0.value !== self } } diff --git a/TablePro/Views/ConnectionForm/Panes/AIRulesPaneView.swift b/TablePro/Views/ConnectionForm/Panes/AIRulesPaneView.swift deleted file mode 100644 index 8301cd74c8..0000000000 --- a/TablePro/Views/ConnectionForm/Panes/AIRulesPaneView.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// AIRulesPaneView.swift -// TablePro -// - -import AppKit -import SwiftUI - -struct AIRulesPaneView: View { - @Bindable var coordinator: ConnectionFormCoordinator - - var body: some View { - Form { - Section { - AIRulesEditor(text: $coordinator.aiRules.rules) - .frame(minHeight: 280) - } header: { - Text(String(localized: "Rules")) - } footer: { - VStack(alignment: .leading, spacing: 4) { - // swiftlint:disable:next line_length - Text("Custom guidance the AI sees on every chat turn for this connection. Use it for table conventions, naming, columns to avoid (PII, soft-deleted rows), join hints, or business rules the schema doesn't show.") - Text(String(localized: "Plain text. Markdown is preserved as written.")) - } - .font(.caption) - .foregroundStyle(.secondary) - } - - Section { - // swiftlint:disable:next line_length - Text(verbatim: "- Tables prefixed with `tmp_` are scratch and safe to ignore\n- `users.email_hash` is the join key, not `users.email`\n- Always filter `orders` by `deleted_at IS NULL`\n- Never select `users.ssn`") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - .textSelection(.enabled) - } header: { - Text(String(localized: "Examples")) - } - } - .formStyle(.grouped) - .scrollContentBackground(.hidden) - } -} - -private struct AIRulesEditor: View { - @Binding var text: String - - var body: some View { - TextValueEditor( - text: $text, - font: .monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular), - borderType: .bezelBorder, - movesFocusOnTab: true - ) - } -} diff --git a/TablePro/Views/ConnectionForm/Panes/AdvancedPaneView.swift b/TablePro/Views/ConnectionForm/Panes/AdvancedPaneView.swift deleted file mode 100644 index 03444dcf27..0000000000 --- a/TablePro/Views/ConnectionForm/Panes/AdvancedPaneView.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// AdvancedPaneView.swift -// TablePro -// - -import SwiftUI - -struct AdvancedPaneView: View { - @Bindable var coordinator: ConnectionFormCoordinator - - var body: some View { - ConnectionAdvancedView( - additionalFieldValues: $coordinator.advanced.additionalFieldValues, - startupCommands: $coordinator.advanced.startupCommands, - preConnectScript: $coordinator.advanced.preConnectScript, - aiPolicy: $coordinator.advanced.aiPolicy, - externalAccess: $coordinator.advanced.externalAccess, - localOnly: $coordinator.advanced.localOnly, - databaseType: coordinator.network.type, - additionalConnectionFields: coordinator.advanced.advancedFields, - visibilityValues: coordinator.allAdditionalFieldValues - ) - } -} diff --git a/TablePro/Views/ConnectionForm/Panes/CustomizationPaneView.swift b/TablePro/Views/ConnectionForm/Panes/AppearancePaneView.swift similarity index 61% rename from TablePro/Views/ConnectionForm/Panes/CustomizationPaneView.swift rename to TablePro/Views/ConnectionForm/Panes/AppearancePaneView.swift index 464e19d672..b33af7f870 100644 --- a/TablePro/Views/ConnectionForm/Panes/CustomizationPaneView.swift +++ b/TablePro/Views/ConnectionForm/Panes/AppearancePaneView.swift @@ -1,16 +1,17 @@ // -// CustomizationPaneView.swift +// AppearancePaneView.swift // TablePro // import SwiftUI -struct CustomizationPaneView: View { +/// How this connection is recognised in the connection list and the window chrome. +struct AppearancePaneView: View { @Bindable var coordinator: ConnectionFormCoordinator var body: some View { Form { - Section(String(localized: "Appearance")) { + Section { LabeledContent(String(localized: "Color")) { ConnectionColorPicker(selectedColor: $coordinator.customization.color) } @@ -20,14 +21,10 @@ struct CustomizationPaneView: View { LabeledContent(String(localized: "Group")) { ConnectionGroupPicker(selectedGroupId: $coordinator.customization.groupId) } - } - - Section(String(localized: "Query Behavior")) { - Picker(String(localized: "Safe Mode"), selection: $coordinator.customization.safeModeLevel) { - ForEach(SafeModeLevel.allCases) { level in - Text(level.displayName).tag(level) - } - } + } footer: { + Text(String(localized: "The color marks this connection in the connection list and its window.")) + .font(.caption) + .foregroundStyle(.secondary) } } .formStyle(.grouped) diff --git a/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift b/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift index 0a275b01b4..81fa49fcae 100644 --- a/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift +++ b/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift @@ -4,11 +4,14 @@ // import AppKit -import Network import SwiftUI import TableProPluginKit import UniformTypeIdentifiers +/// What a connection is: its name, its type, where it lives, and who it signs in as. +/// +/// Everything about how the bytes get there belongs to `NetworkPaneView`, so a connection that +/// needs no tunnel and no TLS never sees a control about either. struct GeneralPaneView: View { @Bindable var coordinator: ConnectionFormCoordinator @FocusState private var nameFocused: Bool @@ -35,35 +38,47 @@ struct GeneralPaneView: View { } } - Section { - TextField( - String(localized: "Name"), - text: $coordinator.network.name, - prompt: Text(String(localized: "Connection name")) - ) - .focused($nameFocused) - .accessibilityIdentifier("connection-form-name") - } - + identitySection connectionSection authenticationSection - testConnectionSection } .formStyle(.grouped) + .scrollContentBackground(.hidden) .defaultFocus($nameFocused, true) } - @ViewBuilder - private var testConnectionSection: some View { + // MARK: - Identity + + private var identitySection: some View { Section { - LabeledContent { - TestConnectionStatusButton(coordinator: coordinator) - } label: { - Text(String(localized: "Status")) + TextField( + String(localized: "Name"), + text: $coordinator.network.name, + prompt: Text(String(localized: "Connection name")) + ) + .focused($nameFocused) + .accessibilityIdentifier("connection-form-name") + + LabeledContent(String(localized: "Type")) { + HStack(spacing: 8) { + type.iconImage + .renderingMode(.template) + .foregroundStyle(type.themeColor) + .frame(width: 16, height: 16) + Text(type.rawValue) + Spacer(minLength: 8) + Button(String(localized: "Change…")) { + coordinator.isChoosingType = true + } + .controlSize(.small) + .accessibilityIdentifier("connection-form-change-type") + } } } } + // MARK: - Connection + @ViewBuilder private var connectionSection: some View { switch connectionMode { @@ -91,11 +106,9 @@ struct GeneralPaneView: View { prompt: Text(containerEntityPlaceholder) ) } - } else { - EmptyView() } case .network: - Section(String(localized: "Connection")) { + Section { hostFieldsView if showsBuiltInDatabaseField { TextField( @@ -104,19 +117,16 @@ struct GeneralPaneView: View { prompt: Text(containerEntityPlaceholder) ) } - } - - if coordinator.ssh.state.enabled && coordinator.network.hasHostListField { - let hostsValue = firstHostListValue - if hostsValue.contains(",") { - Section { - Label( - String(localized: "Over an SSH tunnel, TablePro connects directly to the first host. Replica set failover is not available."), - systemImage: "exclamationmark.triangle" - ) - .font(.caption) - .foregroundStyle(.secondary) - } + } header: { + Text(String(localized: "Connection")) + } footer: { + if usesForwardSocket { + Text(String(localized: """ + Host and Port are unused. The SSH tunnel forwards to the socket path set \ + on the Network tab. + """)) + .font(.caption) + .foregroundStyle(.secondary) } } } @@ -161,9 +171,6 @@ struct GeneralPaneView: View { .accessibilityIdentifier("connection-form-port") .disabled(usesForwardSocket) } - if coordinator.ssh.state.enabled { - sshForwardSocketField - } ForEach(connectionFields, id: \.id) { field in if !isHostListField(field) && coordinator.network.isFieldVisible(field) { ConnectionFieldRow( @@ -178,63 +185,14 @@ struct GeneralPaneView: View { coordinator.ssh.state.enabled && coordinator.network.forwardsToUnixSocket } - @ViewBuilder - private var sshForwardSocketField: some View { - TextField( - String(localized: "Socket Path"), - text: $coordinator.network.sshForwardUnixSocketPath, - prompt: Text(verbatim: coordinator.network.socketPathPrompt) - ) - switch coordinator.network.socketPathIssue { - case .notAbsolute: - socketPathCaption( - String(localized: "Enter an absolute path, as it appears on the SSH server."), - systemImage: "exclamationmark.triangle", - tint: .orange - ) - case .looksLikeDirectory: - socketPathCaption( - String(localized: "Point at the socket file itself, not the directory holding it."), - systemImage: "exclamationmark.triangle", - tint: .orange - ) - case .none: - if usesForwardSocket { - socketPathCaption( - String(localized: """ - The SSH server connects to this socket instead of Host and Port. \ - A database on a socket cannot negotiate TLS, so TablePro turns it off; \ - the SSH tunnel still encrypts the whole path. - """), - systemImage: "info.circle", - tint: .secondary - ) - } else { - socketPathCaption( - String(localized: "Optional. Set this to reach a database that only listens on a Unix socket."), - systemImage: "info.circle", - tint: .secondary - ) - } - } - } - - private func socketPathCaption( - _ message: String, - systemImage: String, - tint: Color - ) -> some View { - Label(message, systemImage: systemImage) - .font(.caption) - .foregroundStyle(tint) - } + // MARK: - Authentication @ViewBuilder private var authenticationSection: some View { if connectionMode != .fileBased { let authFields = coordinator.auth.authFields.splitCredentialControllers() Section(String(localized: "Authentication")) { - ForEach(authFields.controllers, id: \.id) { field in + ForEach(authFields.usernameControllers, id: \.id) { field in authFieldRow(field) } if connectionMode == .network && !coordinator.auth.hidesUsername { @@ -242,6 +200,10 @@ struct GeneralPaneView: View { String(localized: "Username"), text: $coordinator.auth.username ) + .accessibilityIdentifier("connection-form-username") + } + ForEach(authFields.passwordControllers, id: \.id) { field in + authFieldRow(field) } if !coordinator.auth.hidesPassword { PasswordPromptToggle( @@ -338,22 +300,16 @@ struct GeneralPaneView: View { } private var hostIsIPAddress: Bool { - let host = coordinator.network.resolvedHost.trimmingCharacters(in: .whitespaces) - return IPv4Address(host) != nil || IPv6Address(host) != nil + coordinator.network.resolvedHostIsIPAddress } + // MARK: - Helpers + private func isHostListField(_ field: ConnectionField) -> Bool { if case .hostList = field.fieldType { return true } return false } - private var firstHostListValue: String { - let fieldId = coordinator.network.connectionFields - .first { isHostListField($0) && coordinator.network.isFieldVisible($0) }?.id - guard let fieldId else { return "" } - return coordinator.network.additionalFieldValues[fieldId] ?? "" - } - private func networkFieldBinding(for field: ConnectionField) -> Binding { Binding( get: { diff --git a/TablePro/Views/ConnectionForm/Panes/NetworkPaneView.swift b/TablePro/Views/ConnectionForm/Panes/NetworkPaneView.swift new file mode 100644 index 0000000000..5d5a52d1d0 --- /dev/null +++ b/TablePro/Views/ConnectionForm/Panes/NetworkPaneView.swift @@ -0,0 +1,78 @@ +// +// NetworkPaneView.swift +// TablePro +// + +import SwiftUI +import TableProPluginKit + +/// How the connection reaches its database: one transport, then how it is encrypted. +/// +/// The five transports used to be five sidebar panes with an Enable switch each, so the only way +/// to find out which one was on was to visit all five, and turning on a second left the connection +/// in a state `DatabaseConnection.activeTunnelKind` reports as no transport at all. One picker +/// makes that state unrepresentable. +struct NetworkPaneView: View { + @Bindable var coordinator: ConnectionFormCoordinator + + var body: some View { + Form { + transportPicker + transportSections + if coordinator.supportsSSL { + SSLSections( + databaseType: coordinator.network.type, + sslMode: $coordinator.ssl.mode, + sslCaCertPath: $coordinator.ssl.caCertPath, + sslClientCertPath: $coordinator.ssl.clientCertPath, + sslClientKeyPath: $coordinator.ssl.clientKeyPath, + sslClientKeyPassphrase: $coordinator.ssl.clientKeyPassphrase + ) + } + } + .formStyle(.grouped) + .scrollContentBackground(.hidden) + } + + private var transportPicker: some View { + Section { + Picker(String(localized: "Connect via"), selection: $coordinator.transport) { + ForEach(coordinator.availableTransports, id: \.self) { transport in + Text(transport?.displayName ?? ConnectionTunnelKind.directDisplayName) + .tag(transport) + } + } + .accessibilityIdentifier("connection-form-transport") + } footer: { + Text(coordinator.transport?.summary ?? directSummary) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + private var directSummary: String { + ConnectionTunnelKind.directSummary( + isFileBased: coordinator.network.connectionMode == .fileBased + ) + } + + @ViewBuilder + private var transportSections: some View { + switch coordinator.transport { + case .none: + EmptyView() + case .ssh: + SSHTransportSections(coordinator: coordinator) + case .remoteFile: + RemoteFileTransportSections(coordinator: coordinator) + case .cloudflare: + CloudflareTransportSections(coordinator: coordinator) + case .cloudSQLProxy: + CloudSQLProxyTransportSections(coordinator: coordinator) + case .socksProxy: + SOCKSProxyTransportSections(coordinator: coordinator) + case .tunnelCommand: + TunnelCommandTransportSections(coordinator: coordinator) + } + } +} diff --git a/TablePro/Views/ConnectionForm/Panes/OptionsPaneView.swift b/TablePro/Views/ConnectionForm/Panes/OptionsPaneView.swift new file mode 100644 index 0000000000..e2fad9ae2d --- /dev/null +++ b/TablePro/Views/ConnectionForm/Panes/OptionsPaneView.swift @@ -0,0 +1,170 @@ +// +// OptionsPaneView.swift +// TablePro +// + +import AppKit +import SwiftUI +import TableProPluginKit + +/// What TablePro is allowed to do with this connection, and what it runs when it opens it. +/// +/// Driver options, startup SQL, Safe Mode, external and AI access and iCloud were split across +/// three sidebar panes called Customization, Advanced and AI Rules. They answer one question, so +/// they are one tab. +struct OptionsPaneView: View { + @Bindable var coordinator: ConnectionFormCoordinator + + private var databaseType: DatabaseType { coordinator.network.type } + private var aiIsEnabled: Bool { AppSettingsManager.shared.ai.enabled } + + var body: some View { + Form { + driverSection + startupSection + preConnectSection + safetySection + if aiIsEnabled { + aiRulesSection + } + if AppSettingsManager.shared.sync.enabled { + syncSection + } + } + .formStyle(.grouped) + .scrollContentBackground(.hidden) + } + + // MARK: - Driver options + + @ViewBuilder + private var driverSection: some View { + let fields = coordinator.advanced.advancedFields + if !fields.isEmpty { + Section(databaseType.displayName) { + ForEach(fields, id: \.id) { field in + if coordinator.advanced.isFieldVisible(field) { + ConnectionFieldRow( + field: field, + value: advancedFieldBinding(for: field) + ) + } + } + } + } + } + + private func advancedFieldBinding(for field: ConnectionField) -> Binding { + Binding( + get: { + coordinator.advanced.additionalFieldValues[field.id] + ?? field.defaultValue ?? "" + }, + set: { coordinator.advanced.additionalFieldValues[field.id] = $0 } + ) + } + + // MARK: - Startup + + private var startupSection: some View { + Section { + StartupCommandsEditor(text: $coordinator.advanced.startupCommands) + .frame(height: 80) + } header: { + Text(String(localized: "Startup Commands")) + } footer: { + Text("SQL commands to run after connecting, e.g. SET time_zone = 'Asia/Ho_Chi_Minh'. One per line or separated by semicolons.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + private var preConnectSection: some View { + Section { + StartupCommandsEditor(text: $coordinator.advanced.preConnectScript) + .frame(height: 80) + } header: { + Text(String(localized: "Pre-Connect Script")) + } footer: { + Text("Shell script to run before connecting. Non-zero exit aborts connection.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + // MARK: - Safety + + private var safetySection: some View { + Section { + Picker(String(localized: "Safe Mode"), selection: $coordinator.customization.safeModeLevel) { + ForEach(SafeModeLevel.allCases) { level in + Text(level.displayName).tag(level) + } + } + if aiIsEnabled { + Picker(String(localized: "AI Policy"), selection: $coordinator.advanced.aiPolicy) { + Text(String(localized: "Use Default")) + .tag(AIConnectionPolicy?.none as AIConnectionPolicy?) + ForEach(AIConnectionPolicy.allCases) { policy in + Text(policy.displayName) + .tag(AIConnectionPolicy?.some(policy) as AIConnectionPolicy?) + } + } + } + Picker(String(localized: "External Clients"), selection: $coordinator.advanced.externalAccess) { + ForEach(ExternalAccessLevel.allCases) { level in + Text(level.displayName).tag(level) + } + } + .pickerStyle(.segmented) + } header: { + Text(String(localized: "Access")) + } footer: { + accessFooter + } + } + + @ViewBuilder + private var accessFooter: some View { + Group { + if aiIsEnabled { + // swiftlint:disable:next line_length + Text(String(localized: "AI Policy controls in-app AI agents. External Clients controls Raycast, Cursor, Claude Desktop, other MCP clients, and AppleScript. Effective scope is the minimum of the requesting token's scope and the External Clients level.")) + } else { + // swiftlint:disable:next line_length + Text(String(localized: "Controls how external clients (Raycast, Cursor, Claude Desktop, AppleScript) access this connection. Tokens cannot exceed this level even with full-access scope.")) + } + } + .font(.caption) + .foregroundStyle(.secondary) + } + + // MARK: - AI rules + + private var aiRulesSection: some View { + Section { + StartupCommandsEditor(text: $coordinator.aiRules.rules) + .frame(height: 120) + } header: { + Text(String(localized: "AI Rules")) + } footer: { + Text("Guidance the AI sees on every chat turn for this connection: table conventions, columns to avoid, join hints, business rules the schema doesn't show.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + // MARK: - Sync + + private var syncSection: some View { + Section { + Toggle(String(localized: "Local only"), isOn: $coordinator.advanced.localOnly) + } header: { + Text(String(localized: "iCloud Sync")) + } footer: { + Text("This connection won't sync to other devices via iCloud.") + .font(.caption) + .foregroundStyle(.secondary) + } + } +} diff --git a/TablePro/Views/ConnectionForm/Panes/RemoteFilePaneView.swift b/TablePro/Views/ConnectionForm/Panes/RemoteFilePaneView.swift deleted file mode 100644 index 25494d2744..0000000000 --- a/TablePro/Views/ConnectionForm/Panes/RemoteFilePaneView.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// RemoteFilePaneView.swift -// TablePro -// - -import SwiftUI - -/// Points a file-backed connection at a database file on an SSH server. -/// -/// The server half is `ConnectionSSHTunnelView`, unchanged, because reaching the machine is the -/// same problem whether what comes back is a socket or a file. What this pane adds is the path, and -/// the decision the path forces: whether edits may be sent back over a file another process may be -/// writing. -struct RemoteFilePaneView: View { - @Bindable var coordinator: ConnectionFormCoordinator - - private var isEnabled: Bool { coordinator.ssh.state.enabled } - - var body: some View { - Form { - Section { - Toggle(isOn: $coordinator.ssh.state.enabled) { - Text("Open a database file on an SSH server") - } - Text( - "The file is copied to this Mac and opened read-only. The original on the server is never written to." - ) - .font(.callout) - .foregroundStyle(.secondary) - } - - if isEnabled { - Section("Remote File") { - TextField("Path", text: $coordinator.ssh.state.remoteFilePath) - .textFieldStyle(.roundedBorder) - .autocorrectionDisabled() - Text("Absolute, or relative to the SSH account's home directory. `~` works.") - .font(.caption) - .foregroundStyle(.secondary) - } - } - - ConnectionSSHTunnelView( - sshState: $coordinator.ssh.state, - databaseType: coordinator.network.type, - coordinator: coordinator - ) - } - .formStyle(.grouped) - } -} diff --git a/TablePro/Views/ConnectionForm/Panes/SSHPaneView.swift b/TablePro/Views/ConnectionForm/Panes/SSHPaneView.swift deleted file mode 100644 index 50076c14fd..0000000000 --- a/TablePro/Views/ConnectionForm/Panes/SSHPaneView.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// SSHPaneView.swift -// TablePro -// - -import SwiftUI - -struct SSHPaneView: View { - @Bindable var coordinator: ConnectionFormCoordinator - - var body: some View { - ConnectionSSHTunnelView( - sshState: $coordinator.ssh.state, - databaseType: coordinator.network.type, - coordinator: coordinator - ) - } -} diff --git a/TablePro/Views/ConnectionForm/Panes/SSLPaneView.swift b/TablePro/Views/ConnectionForm/Panes/SSLPaneView.swift deleted file mode 100644 index 330f846f33..0000000000 --- a/TablePro/Views/ConnectionForm/Panes/SSLPaneView.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// SSLPaneView.swift -// TablePro -// - -import SwiftUI - -struct SSLPaneView: View { - @Bindable var coordinator: ConnectionFormCoordinator - - var body: some View { - ConnectionSSLView( - databaseType: coordinator.network.type, - sslMode: $coordinator.ssl.mode, - sslCaCertPath: $coordinator.ssl.caCertPath, - sslClientCertPath: $coordinator.ssl.clientCertPath, - sslClientKeyPath: $coordinator.ssl.clientKeyPath, - sslClientKeyPassphrase: $coordinator.ssl.clientKeyPassphrase - ) - } -} diff --git a/TablePro/Views/Connection/ConnectionSSLView.swift b/TablePro/Views/ConnectionForm/Panes/SSLSections.swift similarity index 94% rename from TablePro/Views/Connection/ConnectionSSLView.swift rename to TablePro/Views/ConnectionForm/Panes/SSLSections.swift index 7e99400bb1..a35aaa5872 100644 --- a/TablePro/Views/Connection/ConnectionSSLView.swift +++ b/TablePro/Views/ConnectionForm/Panes/SSLSections.swift @@ -1,15 +1,18 @@ // -// ConnectionSSLView.swift +// SSLSections.swift // TablePro // -// Created by Ngo Quoc Dat on 31/3/26. -// import SwiftUI import TableProPluginKit import UniformTypeIdentifiers -struct ConnectionSSLView: View { +/// Transport encryption, as sections of the Network tab's form. +/// +/// It sits under the transport picker because it answers the same question: how the bytes get +/// there. As its own sidebar pane it was one more destination to visit for a setting most +/// connections leave alone. +struct SSLSections: View { let databaseType: DatabaseType @Binding var sslMode: SSLMode @Binding var sslCaCertPath: String @@ -27,7 +30,7 @@ struct ConnectionSSLView: View { } var body: some View { - Form { + Group { Section { Picker(String(localized: "SSL Mode"), selection: $sslMode) { ForEach(SSLMode.allCases) { mode in @@ -122,8 +125,6 @@ struct ConnectionSSLView: View { } } } - .formStyle(.grouped) - .scrollContentBackground(.hidden) } private func browseForCertificate(binding: Binding) { diff --git a/TablePro/Views/ConnectionForm/Panes/CloudSQLProxyPaneView.swift b/TablePro/Views/ConnectionForm/Panes/Transports/CloudSQLProxyTransportSections.swift similarity index 87% rename from TablePro/Views/ConnectionForm/Panes/CloudSQLProxyPaneView.swift rename to TablePro/Views/ConnectionForm/Panes/Transports/CloudSQLProxyTransportSections.swift index 175b450a68..36e858e57a 100644 --- a/TablePro/Views/ConnectionForm/Panes/CloudSQLProxyPaneView.swift +++ b/TablePro/Views/ConnectionForm/Panes/Transports/CloudSQLProxyTransportSections.swift @@ -1,37 +1,22 @@ // -// CloudSQLProxyPaneView.swift +// CloudSQLProxyTransportSections.swift // TablePro // import AppKit import SwiftUI -struct CloudSQLProxyPaneView: View { +struct CloudSQLProxyTransportSections: View { @Bindable var coordinator: ConnectionFormCoordinator private var viewModel: CloudSQLProxyPaneViewModel { coordinator.cloudSQLProxy } var body: some View { - Form { - Section { - Toggle(String(localized: "Enable Cloud SQL Auth Proxy"), isOn: $coordinator.cloudSQLProxy.state.enabled) - } footer: { - Text("Starts and stops the Cloud SQL Auth Proxy with this connection and routes it through a local port.") - } - - if coordinator.cloudSQLProxy.state.enabled { - if !coordinator.otherEnabledTunnels(excluding: .cloudSQLProxy).isEmpty { - TunnelExclusivityBanner(coordinator: coordinator, currentKind: .cloudSQLProxy) - } - instanceSection - authenticationSection - networkSection - listenerSection - binarySection - } - } - .formStyle(.grouped) - .scrollContentBackground(.hidden) + instanceSection + authenticationSection + networkSection + listenerSection + binarySection } // MARK: - Sections diff --git a/TablePro/Views/ConnectionForm/Panes/CloudflareTunnelPaneView.swift b/TablePro/Views/ConnectionForm/Panes/Transports/CloudflareTransportSections.swift similarity index 84% rename from TablePro/Views/ConnectionForm/Panes/CloudflareTunnelPaneView.swift rename to TablePro/Views/ConnectionForm/Panes/Transports/CloudflareTransportSections.swift index cb0580c1de..5d41398de6 100644 --- a/TablePro/Views/ConnectionForm/Panes/CloudflareTunnelPaneView.swift +++ b/TablePro/Views/ConnectionForm/Panes/Transports/CloudflareTransportSections.swift @@ -1,36 +1,21 @@ // -// CloudflareTunnelPaneView.swift +// CloudflareTransportSections.swift // TablePro // import AppKit import SwiftUI -struct CloudflareTunnelPaneView: View { +struct CloudflareTransportSections: View { @Bindable var coordinator: ConnectionFormCoordinator private var viewModel: CloudflareTunnelPaneViewModel { coordinator.cloudflareTunnel } var body: some View { - Form { - Section { - Toggle(String(localized: "Enable Cloudflare Tunnel"), isOn: $coordinator.cloudflareTunnel.state.enabled) - } footer: { - Text("Starts and stops `cloudflared access tcp` with this connection and routes it through a local port.") - } - - if coordinator.cloudflareTunnel.state.enabled { - if !coordinator.otherEnabledTunnels(excluding: .cloudflare).isEmpty { - TunnelExclusivityBanner(coordinator: coordinator, currentKind: .cloudflare) - } - hostnameSection - authenticationSection - listenerSection - binarySection - } - } - .formStyle(.grouped) - .scrollContentBackground(.hidden) + hostnameSection + authenticationSection + listenerSection + binarySection } // MARK: - Sections diff --git a/TablePro/Views/ConnectionForm/Panes/SOCKSProxyPaneView.swift b/TablePro/Views/ConnectionForm/Panes/Transports/SOCKSProxyTransportSections.swift similarity index 58% rename from TablePro/Views/ConnectionForm/Panes/SOCKSProxyPaneView.swift rename to TablePro/Views/ConnectionForm/Panes/Transports/SOCKSProxyTransportSections.swift index ae0d0596de..ca2358c692 100644 --- a/TablePro/Views/ConnectionForm/Panes/SOCKSProxyPaneView.swift +++ b/TablePro/Views/ConnectionForm/Panes/Transports/SOCKSProxyTransportSections.swift @@ -1,31 +1,16 @@ // -// SOCKSProxyPaneView.swift +// SOCKSProxyTransportSections.swift // TablePro // import SwiftUI -struct SOCKSProxyPaneView: View { +struct SOCKSProxyTransportSections: View { @Bindable var coordinator: ConnectionFormCoordinator var body: some View { - Form { - Section { - Toggle(String(localized: "Enable SOCKS Proxy"), isOn: $coordinator.socksProxy.state.enabled) - } footer: { - Text("Routes this connection through a SOCKS5 proxy. The database hostname is resolved by the proxy, so names that only resolve behind it still work.") - } - - if coordinator.socksProxy.state.enabled { - if !coordinator.otherEnabledTunnels(excluding: .socksProxy).isEmpty { - TunnelExclusivityBanner(coordinator: coordinator, currentKind: .socksProxy) - } - serverSection - credentialsSection - } - } - .formStyle(.grouped) - .scrollContentBackground(.hidden) + serverSection + credentialsSection } private var serverSection: some View { @@ -36,6 +21,7 @@ struct SOCKSProxyPaneView: View { prompt: Text(verbatim: "proxy.example.com") ) .autocorrectionDisabled() + .accessibilityIdentifier("connection-form-socks-host") TextField( String(localized: "Port"), text: $coordinator.socksProxy.state.port, diff --git a/TablePro/Views/Connection/ConnectionSSHTunnelView.swift b/TablePro/Views/ConnectionForm/Panes/Transports/SSHServerSections.swift similarity index 91% rename from TablePro/Views/Connection/ConnectionSSHTunnelView.swift rename to TablePro/Views/ConnectionForm/Panes/Transports/SSHServerSections.swift index dd09746b62..40405853bc 100644 --- a/TablePro/Views/Connection/ConnectionSSHTunnelView.swift +++ b/TablePro/Views/ConnectionForm/Panes/Transports/SSHServerSections.swift @@ -1,54 +1,35 @@ // -// ConnectionSSHTunnelView.swift +// SSHServerSections.swift // TablePro // -// Created by Ngo Quoc Dat on 31/3/26. -// import SwiftUI -struct ConnectionSSHTunnelView: View { +/// Which SSH server to reach, and how to sign in to it. +/// +/// Sections rather than a `Form`, because the Network tab is the form. It used to wrap itself in +/// one and be embedded inside another by the Remote File pane, which is a grouped form nested in a +/// grouped form and inset twice. +struct SSHServerSections: View { @Binding var sshState: SSHTunnelFormState - let databaseType: DatabaseType - var coordinator: ConnectionFormCoordinator? - var body: some View { - Form { - Section { - Toggle(String(localized: "Enable SSH Tunnel"), isOn: $sshState.enabled) - .onChange(of: sshState.enabled) { - if !sshState.enabled { - sshState.disable() - } - } - } - - if sshState.enabled { - if let coordinator, !coordinator.otherEnabledTunnels(excluding: .ssh).isEmpty { - TunnelExclusivityBanner(coordinator: coordinator, currentKind: .ssh) - } - - sshProfileSection + sshProfileSection - if sshState.selectedProfile == nil, sshState.profileId != nil { - Section { - HStack { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.yellow) - Text("Selected SSH profile no longer exists.") - } - Button("Switch to Inline Configuration") { - sshState.profileId = nil - } - } - } else if sshState.selectedProfile == nil { - sshInlineFields + if sshState.selectedProfile == nil, sshState.profileId != nil { + Section { + Label( + String(localized: "Selected SSH profile no longer exists."), + systemImage: "exclamationmark.triangle.fill" + ) + .foregroundStyle(.yellow) + Button(String(localized: "Switch to Inline Configuration")) { + sshState.profileId = nil } } + } else if sshState.selectedProfile == nil { + sshInlineFields } - .formStyle(.grouped) - .scrollContentBackground(.hidden) } // MARK: - SSH Profile Section @@ -173,6 +154,7 @@ struct ConnectionSSHTunnelView: View { } if sshState.selectedConfigHost.isEmpty || sshState.configEntries.isEmpty { TextField(String(localized: "SSH Host"), text: $sshState.host, prompt: Text("ssh.example.com")) + .accessibilityIdentifier("connection-form-ssh-host") } TextField(String(localized: "SSH Port"), text: $sshState.port, prompt: Text("22")) TextField(String(localized: "SSH User"), text: $sshState.username, prompt: Text("username")) diff --git a/TablePro/Views/ConnectionForm/Panes/Transports/SSHTransportSections.swift b/TablePro/Views/ConnectionForm/Panes/Transports/SSHTransportSections.swift new file mode 100644 index 0000000000..ded60fe215 --- /dev/null +++ b/TablePro/Views/ConnectionForm/Panes/Transports/SSHTransportSections.swift @@ -0,0 +1,119 @@ +// +// SSHTransportSections.swift +// TablePro +// + +import AppKit +import SwiftUI + +/// The SSH server half of a connection, as sections of the Network tab's form. +/// +/// Nothing here switches the transport on: reaching this view at all means the picker already +/// selected it, which is why the old `Toggle("Enable SSH Tunnel")` that made up an entire pane on +/// its own is gone. +struct SSHTransportSections: View { + @Bindable var coordinator: ConnectionFormCoordinator + + var body: some View { + SSHServerSections(sshState: $coordinator.ssh.state) + forwardTargetSection + } + + /// A tunnel normally forwards to Host and Port; a database that only listens on a unix socket + /// needs the path instead, and then Host and Port go unused. The field belongs beside the + /// tunnel that carries it rather than beside the host it replaces. + private var forwardTargetSection: some View { + Section { + TextField( + String(localized: "Socket Path"), + text: $coordinator.network.sshForwardUnixSocketPath, + prompt: Text(verbatim: coordinator.network.socketPathPrompt) + ) + if coordinator.network.hasHostListField, replicaSetHostsAreListed { + Label( + String(localized: "TablePro connects to the first host over a tunnel. Replica set failover is not available."), + systemImage: "exclamationmark.triangle" + ) + .font(.caption) + .foregroundStyle(.secondary) + } + } header: { + Text(String(localized: "Forward To")) + } footer: { + forwardTargetFooter + } + } + + @ViewBuilder + private var forwardTargetFooter: some View { + switch coordinator.network.socketPathIssue { + case .notAbsolute: + caption( + String(localized: "Enter an absolute path, as it appears on the SSH server."), + systemImage: "exclamationmark.triangle", + tint: .orange + ) + case .looksLikeDirectory: + caption( + String(localized: "Point at the socket file itself, not the directory holding it."), + systemImage: "exclamationmark.triangle", + tint: .orange + ) + case .none: + if coordinator.network.forwardsToUnixSocket { + caption( + String(localized: """ + The SSH server connects to this socket instead of Host and Port. \ + A database on a socket cannot negotiate TLS, so TablePro turns it off; \ + the SSH tunnel still encrypts the whole path. + """), + systemImage: "info.circle", + tint: .secondary + ) + } else { + caption( + String(localized: "Optional. Set this to reach a database that only listens on a Unix socket."), + systemImage: "info.circle", + tint: .secondary + ) + } + } + } + + private func caption(_ message: String, systemImage: String, tint: Color) -> some View { + Label(message, systemImage: systemImage) + .font(.caption) + .foregroundStyle(tint) + } + + private var replicaSetHostsAreListed: Bool { + coordinator.network.firstHostListValue.contains(",") + } +} + +/// Points a file-backed connection at a database file on an SSH server. +/// +/// The server half is the same problem whether what comes back is a socket or a file, so it is the +/// same view. What this adds is the path. +struct RemoteFileTransportSections: View { + @Bindable var coordinator: ConnectionFormCoordinator + + var body: some View { + Section { + TextField(String(localized: "Path"), text: $coordinator.ssh.state.remoteFilePath) + .autocorrectionDisabled() + .accessibilityIdentifier("connection-form-remote-file-path") + } header: { + Text(String(localized: "Remote File")) + } footer: { + VStack(alignment: .leading, spacing: 4) { + Text("Absolute, or relative to the SSH account's home directory. `~` works.") + Text("The file is copied to this Mac and opened read-only. The original on the server is never written to.") + } + .font(.caption) + .foregroundStyle(.secondary) + } + + SSHServerSections(sshState: $coordinator.ssh.state) + } +} diff --git a/TablePro/Views/ConnectionForm/Panes/TunnelCommandPaneView.swift b/TablePro/Views/ConnectionForm/Panes/Transports/TunnelCommandTransportSections.swift similarity index 85% rename from TablePro/Views/ConnectionForm/Panes/TunnelCommandPaneView.swift rename to TablePro/Views/ConnectionForm/Panes/Transports/TunnelCommandTransportSections.swift index c60ea4e32c..0e1a9a0d11 100644 --- a/TablePro/Views/ConnectionForm/Panes/TunnelCommandPaneView.swift +++ b/TablePro/Views/ConnectionForm/Panes/Transports/TunnelCommandTransportSections.swift @@ -1,40 +1,19 @@ // -// TunnelCommandPaneView.swift +// TunnelCommandTransportSections.swift // TablePro // import SwiftUI -struct TunnelCommandPaneView: View { +struct TunnelCommandTransportSections: View { @Bindable var coordinator: ConnectionFormCoordinator private var viewModel: TunnelCommandPaneViewModel { coordinator.tunnelCommand } var body: some View { - Form { - Section { - Toggle(String(localized: "Enable Tunnel Command"), isOn: $coordinator.tunnelCommand.state.enabled) - } footer: { - Text( - """ - Runs a command that forwards a local port to this database, and holds it for \ - as long as the connection is open. The local port is picked here, and the \ - command is started again if it stops. - """ - ) - } - - if coordinator.tunnelCommand.state.enabled { - if !coordinator.otherEnabledTunnels(excluding: .tunnelCommand).isEmpty { - TunnelExclusivityBanner(coordinator: coordinator, currentKind: .tunnelCommand) - } - methodSection - methodFieldsSection - previewSection - } - } - .formStyle(.grouped) - .scrollContentBackground(.hidden) + methodSection + methodFieldsSection + previewSection } // MARK: - Sections diff --git a/TablePro/Views/ConnectionForm/Sidebar/ConnectionFormSidebar.swift b/TablePro/Views/ConnectionForm/Sidebar/ConnectionFormSidebar.swift index 9b38bc5871..e5fca341a2 100644 --- a/TablePro/Views/ConnectionForm/Sidebar/ConnectionFormSidebar.swift +++ b/TablePro/Views/ConnectionForm/Sidebar/ConnectionFormSidebar.swift @@ -5,14 +5,25 @@ import SwiftUI +/// The editor's section list. +/// +/// Follows the app's own `NavigationSplitView` pattern (`IntegrationsActivityView`): a +/// `List(selection:)` of `Label` rows at `.listStyle(.sidebar)`, with the column width declared on +/// the sidebar rather than the detail. +/// +/// The badge is the part the old eleven-pane sidebar got wrong. It showed the same red triangle with +/// no text anywhere in the window, so a dimmed Save had no explanation. The strings existed the +/// whole time; `ConnectionFormTab.validationIssues(for:)` returns them, and the action bar spells +/// out the first one. struct ConnectionFormSidebar: View { @Bindable var coordinator: ConnectionFormCoordinator var body: some View { - List(selection: $coordinator.selectedPane) { - ForEach(coordinator.visiblePanes) { pane in - row(for: pane) - .tag(pane) + List(selection: $coordinator.selectedTab) { + ForEach(coordinator.visibleTabs) { tab in + row(for: tab) + .tag(tab) + .accessibilityIdentifier("connection-form-section-\(tab.rawValue)") } } .listStyle(.sidebar) @@ -20,20 +31,22 @@ struct ConnectionFormSidebar: View { } @ViewBuilder - private func row(for pane: ConnectionFormPane) -> some View { - let badgeIcon = pane.validationBadge(for: coordinator) + private func row(for tab: ConnectionFormTab) -> some View { + let issues = tab.validationIssues(for: coordinator) Label { HStack(spacing: 6) { - Text(pane.title) + Text(tab.title) Spacer(minLength: 4) - if let badgeIcon { - Image(systemName: badgeIcon) - .foregroundStyle(.red) + if let first = issues.first { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) .font(.caption) + .help(issues.joined(separator: "\n")) + .accessibilityLabel(first) } } } icon: { - Image(systemName: pane.systemImage) + Image(systemName: tab.systemImage) } } } diff --git a/TablePro/Views/ConnectionForm/Toolbar/ConnectionFormToolbar.swift b/TablePro/Views/ConnectionForm/Toolbar/ConnectionFormToolbar.swift deleted file mode 100644 index 0fe022f18c..0000000000 --- a/TablePro/Views/ConnectionForm/Toolbar/ConnectionFormToolbar.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// ConnectionFormToolbar.swift -// TablePro -// - -import SwiftUI - -struct ConnectionFormToolbar: ToolbarContent { - @Bindable var coordinator: ConnectionFormCoordinator - - var body: some ToolbarContent { - ToolbarItem(placement: .cancellationAction) { - Button(String(localized: "Cancel")) { - coordinator.cancel() - } - .keyboardShortcut(.cancelAction) - } - - if coordinator.isNew { - ToolbarItem(placement: .confirmationAction) { - Button(String(localized: "Save")) { - coordinator.save() - } - .disabled(!coordinator.isFormValid || coordinator.isInstallingPlugin) - } - } - - ToolbarItem(placement: .confirmationAction) { - Button(coordinator.isNew - ? String(localized: "Save & Connect") - : String(localized: "Save")) { - coordinator.saveAndConnect() - } - .keyboardShortcut(.defaultAction) - .buttonStyle(.borderedProminent) - .disabled(!coordinator.isFormValid || coordinator.isInstallingPlugin) - } - } -} diff --git a/TablePro/Views/ConnectionForm/ViewModels/CloudSQLProxyPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/CloudSQLProxyPaneViewModel.swift index da87263573..a4862e01e4 100644 --- a/TablePro/Views/ConnectionForm/ViewModels/CloudSQLProxyPaneViewModel.swift +++ b/TablePro/Views/ConnectionForm/ViewModels/CloudSQLProxyPaneViewModel.swift @@ -41,14 +41,6 @@ final class CloudSQLProxyPaneViewModel { issues.append(String(localized: "A service account key is required")) } - for other in coordinator?.value?.otherEnabledTunnels(excluding: .cloudSQLProxy) ?? [] { - issues.append(String( - format: String(localized: "Cannot use %@ and %@ at the same time"), - other.kind.displayName, - ConnectionTunnelKind.cloudSQLProxy.displayName - )) - } - return issues } diff --git a/TablePro/Views/ConnectionForm/ViewModels/CloudflareTunnelPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/CloudflareTunnelPaneViewModel.swift index 6e2f1dddb4..f26409926b 100644 --- a/TablePro/Views/ConnectionForm/ViewModels/CloudflareTunnelPaneViewModel.swift +++ b/TablePro/Views/ConnectionForm/ViewModels/CloudflareTunnelPaneViewModel.swift @@ -43,14 +43,6 @@ final class CloudflareTunnelPaneViewModel { } } - for other in coordinator?.value?.otherEnabledTunnels(excluding: .cloudflare) ?? [] { - issues.append(String( - format: String(localized: "Cannot use %@ and %@ at the same time"), - other.kind.displayName, - ConnectionTunnelKind.cloudflare.displayName - )) - } - return issues } diff --git a/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel.swift index 4535da1c6a..3f60beabf5 100644 --- a/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel.swift +++ b/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel.swift @@ -4,6 +4,7 @@ // import Foundation +import Network import TableProPluginKit @Observable @@ -44,6 +45,15 @@ final class NetworkPaneViewModel { return port == 0 ? "" : String(port) } + var firstHostListValue: String { + let fieldId = connectionFields.first { field in + guard case .hostList = field.fieldType else { return false } + return isFieldVisible(field) + }?.id + guard let fieldId else { return "" } + return additionalFieldValues[fieldId] ?? "" + } + var socketPathPrompt: String { PluginManager.shared.defaultUnixSocketPath(for: type) ?? "/path/to/database.sock" } @@ -56,6 +66,13 @@ final class NetworkPaneViewModel { Int(port) ?? type.defaultPort } + /// Kerberos service principals are not registered against IP addresses, so SQL Server's + /// Windows Authentication warns when the host is one. + var resolvedHostIsIPAddress: Bool { + let host = resolvedHost.trimmingCharacters(in: .whitespaces) + return IPv4Address(host) != nil || IPv6Address(host) != nil + } + var hidesBuiltInDatabase: Bool { PluginMetadataRegistry.shared.snapshot(for: type)? .connection.hidesBuiltInDatabase ?? false diff --git a/TablePro/Views/ConnectionForm/ViewModels/SOCKSProxyPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/SOCKSProxyPaneViewModel.swift index 54d973085c..b4f71441db 100644 --- a/TablePro/Views/ConnectionForm/ViewModels/SOCKSProxyPaneViewModel.swift +++ b/TablePro/Views/ConnectionForm/ViewModels/SOCKSProxyPaneViewModel.swift @@ -25,14 +25,6 @@ final class SOCKSProxyPaneViewModel { issues.append(String(localized: "SOCKS proxy port must be between 1 and 65535")) } - for other in coordinator?.value?.otherEnabledTunnels(excluding: .socksProxy) ?? [] { - issues.append(String( - format: String(localized: "Cannot use %@ and %@ at the same time"), - other.kind.displayName, - ConnectionTunnelKind.socksProxy.displayName - )) - } - return issues } diff --git a/TablePro/Views/ConnectionForm/ViewModels/SSHPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/SSHPaneViewModel.swift index 88f74c8511..caf23a66bf 100644 --- a/TablePro/Views/ConnectionForm/ViewModels/SSHPaneViewModel.swift +++ b/TablePro/Views/ConnectionForm/ViewModels/SSHPaneViewModel.swift @@ -14,15 +14,8 @@ final class SSHPaneViewModel { var validationIssues: [String] { guard state.enabled else { return [] } + guard state.profileId == nil else { return [] } var issues: [String] = [] - for other in coordinator?.value?.otherEnabledTunnels(excluding: .ssh) ?? [] { - issues.append(String( - format: String(localized: "Cannot use %@ and %@ at the same time"), - other.kind.displayName, - ConnectionTunnelKind.ssh.displayName - )) - } - guard state.profileId == nil else { return issues } if state.host.trimmingCharacters(in: .whitespaces).isEmpty { issues.append(String(localized: "SSH host is required")) } diff --git a/TablePro/Views/ConnectionForm/ViewModels/SSLPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/SSLPaneViewModel.swift index de9dfae0d9..7a856eef71 100644 --- a/TablePro/Views/ConnectionForm/ViewModels/SSLPaneViewModel.swift +++ b/TablePro/Views/ConnectionForm/ViewModels/SSLPaneViewModel.swift @@ -17,7 +17,10 @@ final class SSLPaneViewModel { var coordinator: WeakCoordinatorRef? + /// Silent on a driver that renders no SSL section, so a stored mode the form cannot show + /// cannot disable Save over a certificate field the user has no way to reach. var validationIssues: [String] { + guard coordinator?.value?.supportsSSL ?? true else { return [] } var issues: [String] = [] if mode == .verifyCa || mode == .verifyIdentity { if caCertPath.trimmingCharacters(in: .whitespaces).isEmpty { diff --git a/TablePro/Views/ConnectionForm/ViewModels/TunnelCommandPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/TunnelCommandPaneViewModel.swift index ab2328dfab..51a533b9f5 100644 --- a/TablePro/Views/ConnectionForm/ViewModels/TunnelCommandPaneViewModel.swift +++ b/TablePro/Views/ConnectionForm/ViewModels/TunnelCommandPaneViewModel.swift @@ -14,17 +14,7 @@ final class TunnelCommandPaneViewModel { var validationIssues: [String] { guard state.enabled else { return [] } - var issues = TunnelCommandBuilder.validationIssues(for: state.buildConfig()) - - for other in coordinator?.value?.otherEnabledTunnels(excluding: .tunnelCommand) ?? [] { - issues.append(String( - format: String(localized: "Cannot use %@ and %@ at the same time"), - other.kind.displayName, - ConnectionTunnelKind.tunnelCommand.displayName - )) - } - - return issues + return TunnelCommandBuilder.validationIssues(for: state.buildConfig()) } func previewCommand(remoteHost: String, remotePort: Int) -> String? { diff --git a/TableProTests/Core/Plugins/AuthFieldOrderTests.swift b/TableProTests/Core/Plugins/AuthFieldOrderTests.swift index 360d535f63..34d133a06d 100644 --- a/TableProTests/Core/Plugins/AuthFieldOrderTests.swift +++ b/TableProTests/Core/Plugins/AuthFieldOrderTests.swift @@ -2,8 +2,9 @@ // AuthFieldOrderTests.swift // TableProTests // -// The connection form renders credential controllers above the built-in Username and -// Password so the selector does not shift position when its own selection hides them. +// The connection form renders every credential controller above what it controls, and no +// further up than that, so the selector does not shift position when its own selection hides +// its dependents and a password-only toggle does not push Username down the section. // import Foundation @@ -51,11 +52,12 @@ struct AuthFieldOrderTests { let split = fields.splitCredentialControllers() - #expect(split.controllers.map(\.id) == ["mssqlAuthMethod"]) + #expect(split.usernameControllers.map(\.id) == ["mssqlAuthMethod"]) + #expect(split.passwordControllers.isEmpty) #expect(split.rest.map(\.id) == ["kerberosPrincipal", "kerberosPassword", "mssqlSchema"]) } - @Test("A selector that hides the password itself is pulled above the credentials too") + @Test("A selector that hides only the password sits above Password, below Username") func selfHidingControllerIsSplitOut() { let fields = [ selector("esAuthMethod", hidesPassword: true), @@ -64,7 +66,8 @@ struct AuthFieldOrderTests { let split = fields.splitCredentialControllers() - #expect(split.controllers.map(\.id) == ["esAuthMethod"]) + #expect(split.usernameControllers.isEmpty) + #expect(split.passwordControllers.map(\.id) == ["esAuthMethod"]) #expect(split.rest.map(\.id) == ["esApiKey"]) } @@ -77,7 +80,8 @@ struct AuthFieldOrderTests { let split = fields.splitCredentialControllers() - #expect(split.controllers.isEmpty) + #expect(split.usernameControllers.isEmpty) + #expect(split.passwordControllers.isEmpty) #expect(split.rest.map(\.id) == ["warehouse", "role"]) } @@ -98,7 +102,8 @@ struct AuthFieldOrderTests { let split = fields.splitCredentialControllers() - #expect(split.controllers.map(\.id) == ["usePgpass", "awsAuth"]) + #expect(split.usernameControllers.isEmpty) + #expect(split.passwordControllers.map(\.id) == ["usePgpass", "awsAuth"]) #expect(split.rest.map(\.id) == ["awsRegion"]) } @@ -111,7 +116,8 @@ struct AuthFieldOrderTests { let split = fields.splitCredentialControllers() - #expect(split.controllers.map(\.id) == ["authLevel"]) + #expect(split.usernameControllers.isEmpty) + #expect(split.passwordControllers.map(\.id) == ["authLevel"]) #expect(split.rest.map(\.id) == ["token"]) } } diff --git a/TableProTests/ViewModels/ConnectionFormTransportTests.swift b/TableProTests/ViewModels/ConnectionFormTransportTests.swift new file mode 100644 index 0000000000..bac65fbdbf --- /dev/null +++ b/TableProTests/ViewModels/ConnectionFormTransportTests.swift @@ -0,0 +1,286 @@ +// +// ConnectionFormTransportTests.swift +// TableProTests +// +// A connection reaches its database through exactly one transport. Two enabled at once made +// `DatabaseConnection.activeTunnelKind` answer nil, and `activeTunnelManager` then opened a +// direct connection to the database host while the form reported both as on. +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@MainActor +@Suite("Connection form transport") +struct ConnectionFormTransportTests { + private func coordinator(type: DatabaseType = .mysql) -> ConnectionFormCoordinator { + let coordinator = ConnectionFormCoordinator(connectionId: nil) + coordinator.network.type = type + return coordinator + } + + private func enabledFlags(_ coordinator: ConnectionFormCoordinator) -> [ConnectionTunnelKind] { + var kinds: [ConnectionTunnelKind] = [] + if coordinator.ssh.state.enabled { kinds.append(.ssh) } + if coordinator.cloudflareTunnel.state.enabled { kinds.append(.cloudflare) } + if coordinator.cloudSQLProxy.state.enabled { kinds.append(.cloudSQLProxy) } + if coordinator.socksProxy.state.enabled { kinds.append(.socksProxy) } + if coordinator.tunnelCommand.state.enabled { kinds.append(.tunnelCommand) } + return kinds + } + + @Test("a new connection is direct") + func defaultsToDirect() { + let coordinator = coordinator() + #expect(coordinator.transport == nil) + #expect(enabledFlags(coordinator).isEmpty) + } + + @Test("selecting a transport enables exactly that one") + func selectionIsExclusive() { + for kind in ConnectionTunnelKind.formToggleable { + let coordinator = coordinator() + coordinator.transport = kind + #expect(enabledFlags(coordinator) == [kind], "\(kind) did not enable alone") + #expect(coordinator.transport == kind, "\(kind) did not round-trip") + } + } + + @Test("switching transport disables the previous one") + func switchingDisablesPrevious() { + for first in ConnectionTunnelKind.formToggleable { + for second in ConnectionTunnelKind.formToggleable where second != first { + let coordinator = coordinator() + coordinator.transport = first + coordinator.transport = second + #expect(enabledFlags(coordinator) == [second], "\(first) survived a switch to \(second)") + } + } + } + + @Test("selecting direct disables every transport") + func directDisablesEverything() { + let coordinator = coordinator() + coordinator.transport = .socksProxy + coordinator.transport = nil + #expect(coordinator.transport == nil) + #expect(enabledFlags(coordinator).isEmpty) + } + + @Test("switching away keeps the transport's configuration for switching back") + func switchingPreservesConfiguration() { + let coordinator = coordinator() + coordinator.transport = .ssh + coordinator.ssh.state.host = "bastion.example.com" + coordinator.ssh.state.username = "deploy" + + coordinator.transport = .socksProxy + #expect(coordinator.ssh.state.host == "bastion.example.com") + + coordinator.transport = .ssh + #expect(coordinator.ssh.state.host == "bastion.example.com") + #expect(coordinator.ssh.state.username == "deploy") + } + + @Test("a connection stored with two transports normalizes to one") + func normalizeCollapsesLegacyMultiTransport() { + let coordinator = coordinator() + coordinator.ssh.state.enabled = true + coordinator.socksProxy.state.enabled = true + coordinator.tunnelCommand.state.enabled = true + + coordinator.normalizeTransport() + + #expect(enabledFlags(coordinator) == [.ssh]) + #expect(coordinator.transport == .ssh) + } + + @Test("a transport the type no longer offers falls back to direct") + func normalizeDropsUnavailableTransport() { + let coordinator = coordinator() + coordinator.transport = .cloudflare + coordinator.network.type = .sqlite + + coordinator.normalizeTransport() + + #expect(coordinator.transport == nil) + #expect(enabledFlags(coordinator).isEmpty) + } + + @Test("direct is always offered, exactly once, and never duplicated") + func availableTransportsAreWellFormed() { + for type in DatabaseType.allKnownTypes { + let coordinator = coordinator(type: type) + let available = coordinator.availableTransports + #expect(available.first == .some(nil), "\(type.rawValue) does not offer direct first") + #expect(Set(available).count == available.count, "\(type.rawValue) offers a duplicate transport") + } + } + + @Test("a file-based type offers the remote file transport rather than a port forward") + func fileBasedTypeUsesRemoteFile() { + let coordinator = coordinator(type: .sqlite) + #expect(coordinator.availableTransports.contains(.remoteFile)) + #expect(!coordinator.availableTransports.contains(.ssh)) + + coordinator.transport = .remoteFile + #expect(coordinator.ssh.state.enabled) + #expect(coordinator.transport == .remoteFile) + } + + @Test("leaving the SSH server clears the remote file path it was carrying") + func leavingSSHClearsRemoteFilePath() { + let coordinator = coordinator(type: .sqlite) + coordinator.transport = .remoteFile + coordinator.ssh.state.remoteFilePath = "/var/db/app.sqlite" + + coordinator.transport = nil + + #expect(coordinator.ssh.state.remoteFilePath.isEmpty) + } + + @Test("selecting a transport clears a passing test result") + func selectionInvalidatesTestResult() { + let coordinator = coordinator() + coordinator.testSucceeded = true + coordinator.transport = .ssh + #expect(!coordinator.testSucceeded) + } + + @Test("the SSH server is offered as one transport, never as both of its flavours") + func sshAndRemoteFileAreNeverOfferedTogether() { + for type in DatabaseType.allKnownTypes { + let available = coordinator(type: type).availableTransports + #expect( + !(available.contains(.ssh) && available.contains(.remoteFile)), + "\(type.rawValue) offers both SSH flavours, so the picker cannot round-trip" + ) + } + } + + @Test("changing the database type returns the connection to direct") + func typeChangeResetsTransport() { + let coordinator = coordinator() + coordinator.transport = .ssh + coordinator.ssh.state.host = "bastion.example.com" + + coordinator.network.setType(.sqlite) + + #expect(coordinator.transport == nil, "A transport means something else on another type") + #expect(enabledFlags(coordinator).isEmpty) + #expect(coordinator.ssh.state.host == "bastion.example.com", "The server itself is kept") + } + + @Test("changing the type clears a remote file path the new type cannot open") + func typeChangeClearsRemoteFilePath() { + let coordinator = coordinator(type: .sqlite) + coordinator.transport = .remoteFile + coordinator.ssh.state.remoteFilePath = "/var/db/app.sqlite" + + coordinator.network.setType(.mysql) + + #expect(coordinator.ssh.state.remoteFilePath.isEmpty) + } + + @Test("leaving the port forward clears the socket path only it can reach") + func leavingSSHClearsForwardSocketPath() { + let coordinator = coordinator() + coordinator.transport = .ssh + coordinator.network.sshForwardUnixSocketPath = "/var/run/postgresql/.s.PGSQL.5432" + + coordinator.transport = .socksProxy + + #expect(coordinator.network.sshForwardUnixSocketPath.isEmpty) + #expect(!coordinator.network.forwardsToUnixSocket) + } + + @Test("an enabled transport always has a tab that shows it") + func anEnabledTransportIsAlwaysReachable() { + for type in DatabaseType.allKnownTypes { + let coordinator = coordinator(type: type) + for kind in coordinator.availableTransports.compactMap({ $0 }) { + coordinator.transport = kind + #expect( + coordinator.visibleTabs.contains(.network), + "\(type.rawValue) hides Network while \(kind.rawValue) is on, so its issues go uncounted" + ) + } + } + } + + /// A retyped connection has to clear the Keychain entries the old type put there, or a + /// SQL Server connection changed to MySQL leaves its Kerberos password stored under the same + /// connection id with nothing in the form able to see or clear it. + @Test("a retyped connection still owns the previous type's secure fields") + func retypeKeepsOwnershipOfTheOldTypesSecrets() { + let manager = PluginManager.shared + var typesWithSecrets: [DatabaseType] = [] + for type in DatabaseType.allKnownTypes + where manager.additionalConnectionFields(for: type).contains(where: \.isSecure) { + typesWithSecrets.append(type) + } + guard let secretive = typesWithSecrets.first else { + Issue.record("No known type declares a secure connection field") + return + } + + let originalIds = Set( + manager.additionalConnectionFields(for: secretive).filter(\.isSecure).map(\.id) + ) + let owned = ConnectionFormCoordinator.secureFieldsOwnedByForm( + currentType: .mysql, + originalType: secretive, + pluginManager: manager + ) + + #expect(originalIds.isSubset(of: Set(owned.map(\.id)))) + #expect(Set(owned.map(\.id)).count == owned.count, "A field shared by both types is listed once") + } + + @Test("a driver with no SSL section cannot be blocked by an SSL rule") + func sslIssuesAreSilentWhereThereIsNoSSLSection() { + let coordinator = coordinator(type: .sqlite) + coordinator.ssl.mode = .verifyCa + coordinator.ssl.caCertPath = "" + + #expect(!coordinator.supportsSSL) + #expect(coordinator.ssl.validationIssues.isEmpty) + + let networked = self.coordinator() + networked.ssl.mode = .verifyCa + networked.ssl.caCertPath = "" + #expect(networked.supportsSSL) + #expect(!networked.ssl.validationIssues.isEmpty, "A driver that shows the field still requires it") + } + + @Test("a URL naming an SSH server opens the form on exactly that transport") + func urlImportSelectsOneTransport() throws { + let url = "mysql+ssh://deploy@bastion.example.com:22/dbuser@db.internal:3306/app" + guard case .success(let parsed) = ConnectionURLParser.parse(url) else { + Issue.record("\(url) should parse") + return + } + + let coordinator = ConnectionFormCoordinator(connectionId: nil, initialParsedURL: parsed) + coordinator.start() + + #expect(coordinator.transport == .ssh) + #expect(enabledFlags(coordinator) == [.ssh]) + #expect(coordinator.ssh.state.host == "bastion.example.com") + } + + @Test("every issue that blocks Save is claimed by a visible tab") + func everyBlockingIssueHasATabToFix() { + let coordinator = coordinator() + coordinator.transport = .socksProxy + coordinator.socksProxy.state.host = "" + + #expect(!coordinator.isFormValid) + let claimed = coordinator.visibleTabs.flatMap { $0.validationIssues(for: coordinator) } + #expect(claimed == coordinator.validationIssues) + #expect(coordinator.firstTabWithIssue != nil) + } +} diff --git a/TableProTests/ViewModels/ConnectionFormTunnelExclusivityTests.swift b/TableProTests/ViewModels/ConnectionFormTunnelExclusivityTests.swift deleted file mode 100644 index 15a067f648..0000000000 --- a/TableProTests/ViewModels/ConnectionFormTunnelExclusivityTests.swift +++ /dev/null @@ -1,82 +0,0 @@ -// -// ConnectionFormTunnelExclusivityTests.swift -// TableProTests -// - -import Foundation -import Testing - -@testable import TablePro - -@MainActor -@Suite("Connection form tunnel exclusivity") -struct ConnectionFormTunnelExclusivityTests { - private func coordinator(enabled: Set) -> ConnectionFormCoordinator { - let coordinator = ConnectionFormCoordinator(connectionId: nil) - coordinator.ssh.state.enabled = enabled.contains(.ssh) - coordinator.cloudflareTunnel.state.enabled = enabled.contains(.cloudflare) - coordinator.cloudSQLProxy.state.enabled = enabled.contains(.cloudSQLProxy) - coordinator.socksProxy.state.enabled = enabled.contains(.socksProxy) - coordinator.tunnelCommand.state.enabled = enabled.contains(.tunnelCommand) - return coordinator - } - - @Test("no enabled tunnels yields an empty list") - func emptyWhenAllDisabled() { - let coordinator = coordinator(enabled: []) - #expect(coordinator.enabledTunnels.isEmpty) - for kind in ConnectionTunnelKind.formToggleable { - #expect(coordinator.otherEnabledTunnels(excluding: kind).isEmpty) - } - } - - @Test("every pair of enabled tunnels warns in both directions") - func pairwiseConflicts() { - let kinds = ConnectionTunnelKind.formToggleable - for first in kinds { - for second in kinds where second != first { - let coordinator = coordinator(enabled: [first, second]) - #expect(coordinator.otherEnabledTunnels(excluding: first).map(\.kind) == [second]) - #expect(coordinator.otherEnabledTunnels(excluding: second).map(\.kind) == [first]) - } - } - } - - @Test("every toggleable tunnel enabled reports all the others per kind") - func allEnabled() { - let coordinator = coordinator(enabled: Set(ConnectionTunnelKind.formToggleable)) - #expect(coordinator.enabledTunnels.count == ConnectionTunnelKind.formToggleable.count) - for kind in ConnectionTunnelKind.formToggleable { - let others = coordinator.otherEnabledTunnels(excluding: kind) - #expect(others.count == ConnectionTunnelKind.formToggleable.count - 1) - #expect(!others.map(\.kind).contains(kind)) - } - } - - @Test("the disable action turns the other tunnel off") - func disableAction() { - let coordinator = coordinator(enabled: [.ssh, .socksProxy]) - let others = coordinator.otherEnabledTunnels(excluding: .socksProxy) - #expect(others.map(\.kind) == [.ssh]) - others.first?.disable() - #expect(!coordinator.ssh.state.enabled) - #expect(coordinator.otherEnabledTunnels(excluding: .socksProxy).isEmpty) - } - - @Test("each pane view model reports cross-tunnel conflicts") - func paneViewModelsReportConflicts() { - let coordinator = coordinator(enabled: Set(ConnectionTunnelKind.formToggleable)) - coordinator.socksProxy.state.host = "proxy.example.com" - coordinator.cloudflareTunnel.state.accessHostname = "db.example.com" - coordinator.cloudSQLProxy.state.instanceConnectionName = "p:r:i" - coordinator.ssh.state.host = "bastion.example.com" - coordinator.tunnelCommand.state.config.kubernetesResource = "service/postgres" - - let others = ConnectionTunnelKind.formToggleable.count - 1 - #expect(coordinator.ssh.validationIssues.count >= others) - #expect(coordinator.cloudflareTunnel.validationIssues.count >= others) - #expect(coordinator.cloudSQLProxy.validationIssues.count >= others) - #expect(coordinator.socksProxy.validationIssues.count >= others) - #expect(coordinator.tunnelCommand.validationIssues.count >= others) - } -} diff --git a/TableProTests/ViewModels/DatabaseTypeChooserModelTests.swift b/TableProTests/ViewModels/DatabaseTypeChooserModelTests.swift new file mode 100644 index 0000000000..cbffd75188 --- /dev/null +++ b/TableProTests/ViewModels/DatabaseTypeChooserModelTests.swift @@ -0,0 +1,172 @@ +// +// DatabaseTypeChooserModelTests.swift +// TableProTests +// +// The chooser's filter matches taglines and category names as well as driver names, so the first +// row on screen is routinely not the best answer. Arming it would let Return commit a driver the +// user never looked at, and on an existing connection a type change resets its credentials, SSL, +// driver options and transport. +// + +import Foundation +import Testing + +@testable import TablePro + +@MainActor +@Suite("Database type chooser model") +struct DatabaseTypeChooserModelTests { + /// Named explicitly rather than taken from `PluginManager`, which loads no plugins under XCTest. + private func model() -> DatabaseTypeChooserModel { + DatabaseTypeChooserModel(types: [.postgresql, .cockroachdb, .pglite, .mysql, .mariadb, .sqlite]) + } + + @Test("a query naming a driver exactly arms that driver, not an alphabetically earlier tagline hit") + func exactNameOutranksATaglineMatch() { + let model = model() + model.searchText = "PostgreSQL" + + #expect( + model.filteredTypes.contains(.cockroachdb), + "CockroachDB's tagline mentions PostgreSQL, so it is still a visible result" + ) + #expect(model.orderedTypes.first != .postgresql, "CockroachDB sorts ahead of it on screen") + #expect(model.highlightedType == .postgresql, "The armed row must be the driver that was named") + } + + @Test("MySQL is armed over MariaDB, whose tagline names it") + func exactNameOutranksAForkTagline() { + let model = model() + model.searchText = "MySQL" + #expect(model.highlightedType == .mysql) + } + + @Test("a prefix arms the driver it begins") + func prefixArmsTheDriver() { + let model = model() + model.searchText = "postgre" + #expect(model.highlightedType == .postgresql) + } + + @Test("a sole match is armed even when only its tagline matched") + func soleTaglineMatchIsArmed() { + let model = model() + model.searchText = "Distributed" + + #expect(model.orderedTypes == [.cockroachdb], "Only CockroachDB's tagline says Distributed") + #expect( + model.highlightedType == .cockroachdb, + "One row is unambiguous, which is the whole point of arming it" + ) + } + + @Test("several matches with no driver-name hit arm nothing") + func ambiguousNonNameMatchArmsNothing() { + let model = model() + model.searchText = DatabaseCategory.relational.displayName + + #expect(model.orderedTypes.count > 1, "Every relational driver matches on category alone") + #expect( + model.orderedTypes.allSatisfy { + !$0.rawValue.lowercased().contains(DatabaseCategory.relational.displayName.lowercased()) + }, + "None of them matched on its own name" + ) + #expect( + model.highlightedType == nil, + "No row is a defensible default among many, so Continue stays dimmed" + ) + } + + @Test("filtering to a single result arms it") + func soleMatchIsArmed() { + let model = model() + model.searchText = "SQLite" + #expect(model.orderedTypes == [.sqlite]) + #expect(model.highlightedType == .sqlite) + } + + @Test("a query matching nothing clears the highlight") + func noMatchClearsTheHighlight() { + let model = model() + model.searchText = "SQLite" + #expect(model.highlightedType == .sqlite) + + model.searchText = "nothing matches this" + #expect(model.orderedTypes.isEmpty) + #expect(model.highlightedType == nil) + } + + @Test("a highlight the query still shows survives further typing") + func visibleHighlightSurvives() { + let model = model() + model.highlightedType = .postgresql + model.searchText = "postgres" + #expect(model.highlightedType == .postgresql) + } + + @Test("clearing the query keeps the driver the user had narrowed to") + func clearingKeepsAVisibleHighlight() { + let model = model() + model.searchText = "SQLite" + #expect(model.highlightedType == .sqlite) + + model.searchText = "" + #expect( + model.highlightedType == .sqlite, + "Every driver is visible again and this one still is, so the choice stands" + ) + } + + @Test("a fresh model with no query arms nothing") + func emptyQueryOnOpenArmsNothing() { + let model = model() + model.searchText = "" + #expect(model.highlightedType == nil, "Opening the chooser must not pre-arm a driver") + } + + @Test("arrowing walks the rows in the order the list draws them") + func arrowingFollowsDisplayOrder() { + let model = model() + let rows = model.orderedTypes + #expect(rows.count > 2) + + model.moveHighlight(by: 1) + #expect(model.highlightedType == rows.first) + + model.moveHighlight(by: 1) + #expect(model.highlightedType == rows[1]) + + model.moveHighlight(by: -1) + #expect(model.highlightedType == rows.first) + } + + @Test("arrowing stops at the ends rather than wrapping") + func arrowingClampsAtTheEnds() { + let model = model() + let rows = model.orderedTypes + + model.highlightedType = rows.first + model.moveHighlight(by: -1) + #expect(model.highlightedType == rows.first) + + model.highlightedType = rows.last + model.moveHighlight(by: 1) + #expect(model.highlightedType == rows.last) + } + + @Test("arrowing up from nothing arms the last row") + func arrowingUpFromNothingArmsTheLast() { + let model = model() + model.moveHighlight(by: -1) + #expect(model.highlightedType == model.orderedTypes.last) + } + + @Test("preselecting an initial type is left alone by the filter it still matches") + func preselectSurvivesAMatchingFilter() { + let model = model() + model.preselect(.mariadb) + model.searchText = "Maria" + #expect(model.highlightedType == .mariadb) + } +} diff --git a/TableProUITests/ConnectionFormTransportUITests.swift b/TableProUITests/ConnectionFormTransportUITests.swift new file mode 100644 index 0000000000..52eaf86f14 --- /dev/null +++ b/TableProUITests/ConnectionFormTransportUITests.swift @@ -0,0 +1,133 @@ +import XCTest + +/// The transport picker is the whole point of the Network tab: choosing one has to put that +/// transport's fields on screen and take the previous one's away. The view models prove the +/// booleans, and nothing below them proves the swap reaches the form. +final class ConnectionFormTransportUITests: UITestCase { + private let transportPicker = "connection-form-transport" + private let sshHost = "connection-form-ssh-host" + private let socksHost = "connection-form-socks-host" + + func testChoosingATransportReplacesThePreviousOnesFields() throws { + let app = try launchApp() + XCTAssertTrue(app.windows.firstMatch.waitToExist(timeout: 10)) + + let form = try openConnectionForm(for: "PostgreSQL", in: app) + selectTab("network", in: form) + + let picker = form.popUpButtons[transportPicker] + XCTAssertTrue(picker.waitToExist(timeout: 10), "The Network tab should offer a Connect via picker") + + XCTAssertFalse(form.textFields[sshHost].exists, "Direct shows no transport fields") + XCTAssertFalse(form.textFields[socksHost].exists) + + select(option: "SSH Tunnel", in: picker) + XCTAssertTrue( + form.textFields[sshHost].waitToExist(timeout: 5), + "Choosing SSH Tunnel should show the SSH server fields" + ) + XCTAssertFalse(form.textFields[socksHost].exists) + + select(option: "SOCKS Proxy", in: picker) + XCTAssertTrue( + form.textFields[socksHost].waitToExist(timeout: 5), + "Choosing SOCKS Proxy should show the proxy fields" + ) + XCTAssertFalse( + form.textFields[sshHost].exists, + "A connection uses one transport, so the SSH fields go when SOCKS is chosen" + ) + + select(option: "Direct", in: picker) + XCTAssertTrue( + waitForPredicate(timeout: 5) { !form.textFields[socksHost].exists }, + "Direct should leave no transport fields on screen" + ) + XCTAssertFalse(form.textFields[sshHost].exists) + } + + func testTheActionBarNamesWhatIsBlockingSave() throws { + let app = try launchApp() + XCTAssertTrue(app.windows.firstMatch.waitToExist(timeout: 10)) + + let form = try openConnectionForm(for: "PostgreSQL", in: app) + + let validation = form.descendants(matching: .any).matching(identifier: "connection-form-validation") + XCTAssertTrue( + waitForPredicate(timeout: 10) { validation.count > 0 }, + "An unnamed connection should say so beside the dimmed Save button" + ) + + let name = form.textFields["connection-form-name"] + XCTAssertTrue(name.waitToExist(timeout: 5)) + name.click() + app.typeText("Probe") + + XCTAssertTrue( + waitForPredicate(timeout: 5) { validation.count == 0 }, + "Naming the connection should clear the message" + ) + } + + // MARK: - Helpers + + /// The sections are a `NavigationSplitView` sidebar, so each row publishes as an outline row + /// rather than the radio button an `NSSegmentedControl` gave. Reached by the row's own + /// identifier, because a sidebar row's label is nested and does not answer a subscript by title. + /// + /// Not finding the row fails the test rather than skipping it: a section list the accessibility + /// tree cannot see is a section list VoiceOver cannot drive. + private func selectTab(_ tab: String, in form: XCUIElement) { + let row = form.descendants(matching: .any) + .matching(identifier: "connection-form-section-\(tab)") + .firstMatch + XCTAssertTrue( + row.waitToExist(timeout: 10), + "No sidebar row identified connection-form-section-\(tab)" + ) + XCTAssertTrue(waitUntilHittable(row, timeout: 10)) + row.click() + } + + private func openConnectionForm(for type: String, in app: XCUIApplication) throws -> XCUIElement { + let newConnection = app.menuBars.menuItems["New Connection…"] + XCTAssertTrue(newConnection.waitToExist(timeout: 10)) + newConnection.click() + + /// Scoped to the sheet, not the app: the welcome window behind it owns a `sidebar-filter` + /// search field that `app.searchFields.firstMatch` reaches first, so the driver name went + /// into the connection filter, the chooser list stayed unfiltered, and the wanted row was + /// never realised. + let sheet = app.sheets.firstMatch + XCTAssertTrue(sheet.waitToExist(timeout: 10), "New Connection… should open the chooser sheet") + + let search = sheet.searchFields.firstMatch + XCTAssertTrue(search.waitToExist(timeout: 10), "The chooser should offer its search field") + XCTAssertTrue(waitUntilHittable(search, timeout: 10)) + search.click() + search.typeText(type) + XCTAssertTrue( + waitForPredicate(timeout: 10) { (search.value as? String) == type }, + "Typing should reach the chooser's search field" + ) + + let row = sheet.outlines.firstMatch.staticTexts + .matching(NSPredicate(format: "value == %@", type)) + .firstMatch + XCTAssertTrue(row.waitToExist(timeout: 10), "The chooser should list \(type)") + XCTAssertTrue(waitUntilHittable(row, timeout: 10)) + row.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).doubleClick() + + let form = app.windows["connection-form"] + XCTAssertTrue(form.waitToExist(timeout: 10), "Choosing \(type) should open the connection form") + return form + } + + private func select(option: String, in picker: XCUIElement) { + XCTAssertTrue(waitUntilHittable(picker, timeout: 10)) + picker.click() + let item = picker.menuItems[option] + XCTAssertTrue(item.waitToExist(timeout: 5), "The transport picker should offer \(option)") + item.click() + } +} diff --git a/TableProUITests/RedisConnectionModeUITests.swift b/TableProUITests/RedisConnectionModeUITests.swift index 7091701901..a52cc3bad2 100644 --- a/TableProUITests/RedisConnectionModeUITests.swift +++ b/TableProUITests/RedisConnectionModeUITests.swift @@ -65,16 +65,28 @@ final class RedisConnectionModeUITests: UITestCase { XCTAssertTrue(newConnection.waitToExist(timeout: 10)) newConnection.click() - let chooser = app.windows.firstMatch - let search = chooser.searchFields.firstMatch + /// Scoped to the sheet, not the window: the chooser is a `.sheet` on the welcome window, so + /// a window-scoped `searchFields.firstMatch` sees the welcome list's own filter first, and + /// both carry the identifier `sidebar-filter`. The driver name then goes into the + /// connection filter and the chooser list is never filtered. + let sheet = app.sheets.firstMatch + XCTAssertTrue(sheet.waitToExist(timeout: 10), "New Connection… should open the chooser sheet") + + let search = sheet.searchFields.firstMatch XCTAssertTrue(search.waitToExist(timeout: 10), "The chooser should offer its search field") + XCTAssertTrue(waitUntilHittable(search, timeout: 10)) search.click() - app.typeText("Redis") + search.typeText("Redis") + XCTAssertTrue( + waitForPredicate(timeout: 10) { (search.value as? String) == "Redis" }, + "Typing should reach the chooser's search field" + ) - let redis = chooser.outlines.firstMatch.staticTexts + let redis = sheet.outlines.firstMatch.staticTexts .matching(NSPredicate(format: "value == %@", "Redis")) .firstMatch XCTAssertTrue(redis.waitToExist(timeout: 10), "The chooser should list Redis") + XCTAssertTrue(waitUntilHittable(redis, timeout: 10)) redis.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).doubleClick() let form = app.windows["connection-form"] diff --git a/docs/connections/cloud-sql-proxy.mdx b/docs/connections/cloud-sql-proxy.mdx index 2698648463..4acb6640d5 100644 --- a/docs/connections/cloud-sql-proxy.mdx +++ b/docs/connections/cloud-sql-proxy.mdx @@ -7,9 +7,9 @@ import HelperPort from "/snippets/helper-port.mdx"; One field carries the whole setup: the instance connection name, `project:region:instance`, which is on the instance's overview page in the Google Cloud console. The proxy itself runs as a child process, started on connect and killed on disconnect. - - Cloud SQL Auth Proxy pane - Cloud SQL Auth Proxy pane + + Instance connection name, authentication and local listener fields + Instance connection name, authentication and local listener fields ## Before you start @@ -32,7 +32,7 @@ Auto-detection covers your `PATH`, `/opt/homebrew/bin`, `/usr/local/bin`, and `~ - Select **Cloud SQL Auth Proxy** and turn **Enable Cloud SQL Auth Proxy** on. A connection carries one method at a time, so an SSH tunnel or SOCKS proxy already enabled has to be switched off here first. + On the **Network** tab, set **Connect via** to **Cloud SQL Auth Proxy**. A connection uses one transport, so choosing this one switches off whichever was selected before. Enter the **Instance connection name**, then choose **Application Default Credentials** or **Service Account Key**. diff --git a/docs/connections/cloudflare-tunnel.mdx b/docs/connections/cloudflare-tunnel.mdx index 4ee5dc1a0a..e7dfcb5a95 100644 --- a/docs/connections/cloudflare-tunnel.mdx +++ b/docs/connections/cloudflare-tunnel.mdx @@ -7,9 +7,9 @@ import HelperPort from "/snippets/helper-port.mdx"; The Access application is yours to create, in the Cloudflare dashboard. This pane takes the hostname of one that already fronts the database, and runs `cloudflared` against it for the life of the connection. - - Cloudflare Tunnel pane - Cloudflare Tunnel pane + + Access hostname, authentication method and local listener fields + Access hostname, authentication method and local listener fields ## Before you start @@ -26,7 +26,7 @@ The pane looks on your `PATH` and in `/opt/homebrew/bin` and `/usr/local/bin`, a - Select **Cloudflare Tunnel** and turn **Enable Cloudflare Tunnel** on. One method per connection: any other tunnel or proxy already enabled gets a button to disable it. + On the **Network** tab, set **Connect via** to **Cloudflare Tunnel**. A connection uses one transport, so choosing this one switches off whichever was selected before. **Hostname** is the Access application's public hostname, `db.example.com` in the example. With **Browser Sign-In** chosen, click **Sign In with Browser…** so the first connect does not stop to ask. diff --git a/docs/connections/connection-form.mdx b/docs/connections/connection-form.mdx index f84fe91bad..a3c8209579 100644 --- a/docs/connections/connection-form.mdx +++ b/docs/connections/connection-form.mdx @@ -1,36 +1,30 @@ --- title: Connection form -description: The nine panes of the connection editor, the fields on each, and which drivers get which +description: The four sections of the connection editor and the fields each driver adds --- -A driver only ever gets the panes it can use, so this form's sidebar is four items long for SQLite and nine for PostgreSQL. A warning triangle on one of them means a required field on that pane is empty. +The editor's sidebar lists the same four sections whatever the driver. Only Network is ever absent, and only for a driver that connects directly and cannot negotiate TLS. - - Connection form - Connection form + + Connection editor with a General, Network, Options and Appearance sidebar, credential fields filling the pane, and Test, Cancel and Save along the bottom + Connection editor with a General, Network, Options and Appearance sidebar, credential fields filling the pane, and Test, Cancel and Save along the bottom -| Pane | Contents | +| Section | Contents | |------|----------| -| **General** | Name, host, port, database, credentials, Test Connection | -| **SSH Tunnel** | Reach a database behind a bastion host. See [SSH Tunneling](/connections/ssh-tunneling) | -| **Remote File** | For SQLite: open a database that lives on an SSH server, read-only. See [Remote Database Files](/connections/remote-database-files) | -| **Cloudflare Tunnel** | Connect through `cloudflared`. See [Cloudflare Tunnel](/connections/cloudflare-tunnel) | -| **Cloud SQL Auth Proxy** | Google Cloud SQL, for MySQL, PostgreSQL, and SQL Server only. See [Cloud SQL Auth Proxy](/connections/cloud-sql-proxy) | -| **SOCKS Proxy** | Route through a SOCKS5 proxy. See [SOCKS Proxy](/connections/socks-proxy) | -| **Tunnel Command** | Hold a `kubectl port-forward`, an AWS SSM session, or a command of your own. See [Tunnel Command](/connections/tunnel-command) | -| **SSL/TLS** | Encryption mode and certificates. See [SSL/TLS](/connections/ssl) | -| **Customization** | Color, tags, group, Safe Mode | -| **Advanced** | Startup commands, pre-connect script, external access, plugin fields | -| **AI Rules** | Per-connection guidance the AI assistant sees on every chat turn. See [AI Assistant](/features/ai-assistant) | - -Only one of SSH Tunnel, Cloudflare Tunnel, Cloud SQL Auth Proxy, SOCKS Proxy, and Tunnel Command can be on at a time. Turning on a second offers a button to switch off the first. +| **General** | Name, database type, host, port, database, credentials | +| **Network** | How the connection reaches the server, and how it is encrypted | +| **Options** | Driver options, startup SQL, Safe Mode, external and AI access, iCloud | +| **Appearance** | Color, tags, group | + +The bar along the bottom carries **Test Connection**, **Cancel** and **Save**. When **Save** is dimmed, the reason sits to its left, prefixed with the tab that holds the empty field. ## General | Field | Description | |-------|-------------| | **Name** | Display name in the connection list | +| **Type** | The database type. **Change…** retypes the connection and resets the fields the old type owned | | **Host** | Empty falls back to the driver's own default, usually `localhost` | | **Port** | Pre-filled from the database type | | **Database** | Optional on drivers that browse every database from one session. PostgreSQL and Redshift do not connect without one | @@ -38,23 +32,48 @@ Only one of SSH Tunnel, Cloudflare Tunnel, Cloud SQL Auth Proxy, SOCKS Proxy, an | **Password** | Stored in the macOS Keychain | | **Prompt for password** | Stores nothing, asks on every connect. Reads **Prompt for API token** on API-only drivers | | **Use Password File** | PostgreSQL, Redshift, and CockroachDB. Reads `~/.pgpass`, and reports underneath whether the file exists, has `chmod 0600`, and holds a matching line | -| **Socket Path** | Optional, and only with an SSH tunnel on. Forwards to a unix socket instead of Host and Port, which are then ignored. See [Forwarding to a unix socket](/connections/ssh-tunneling#forwarding-to-a-unix-socket) | -SQLite, DuckDB, and Beancount replace the host section with a file path picker. +SQLite, DuckDB, and Beancount replace the host fields with a file path picker. + +## Network + +**Connect via** is a single choice, and the fields under it belong to whichever transport is selected. A connection uses one transport or none. + +| Choice | What it does | +|--------|--------------| +| **Direct** | Connects straight to the host and port on General | +| **SSH Tunnel** | Forwards a local port through an SSH server. See [SSH Tunneling](/connections/ssh-tunneling) | +| **Remote Database File** | SQLite only. Copies the file from an SSH server and opens the copy read-only. See [Remote Database Files](/connections/remote-database-files) | +| **Cloudflare Tunnel** | Runs `cloudflared access tcp`. See [Cloudflare Tunnel](/connections/cloudflare-tunnel) | +| **Cloud SQL Auth Proxy** | MySQL, PostgreSQL, and SQL Server. See [Cloud SQL Auth Proxy](/connections/cloud-sql-proxy) | +| **SOCKS Proxy** | Routes through a SOCKS5 proxy, which also resolves the hostname. See [SOCKS Proxy](/connections/socks-proxy) | +| **Tunnel Command** | Holds a `kubectl port-forward`, an AWS SSM session, or a command of your own. See [Tunnel Command](/connections/tunnel-command) | + +Leave it on **Direct** unless the database is unreachable from this Mac. Switching to another transport keeps what you typed into the previous one, so switching back costs nothing. -## Advanced +**Socket Path**, under **SSH Tunnel**, forwards to a unix socket instead of Host and Port, which are then unused. See [Forwarding to a unix socket](/connections/ssh-tunneling#forwarding-to-a-unix-socket). + +Encryption sits below the transport. [SSL/TLS](/connections/ssl) covers the modes and the certificate fields. + +## Options | Field | Description | |-------|-------------| | **Startup Commands** | SQL to run after every connect. See [Startup commands](#startup-commands) | | **Pre-Connect Script** | Shell script run before connecting. A non-zero exit aborts the connect | +| **Safe Mode** | Confirmation prompts before writes. See [Safe Mode](/features/safe-mode) | | **AI Policy** | Per-connection override for the in-app AI agents. **Never** also refuses external clients | +| **AI Rules** | Guidance the AI reads on every chat turn for this connection: table conventions, columns to avoid, join hints. See [AI Assistant](/features/ai-assistant) | | **External Clients** | **Blocked**, **Read Only** (the default), or **Read & Write** for MCP clients such as Raycast, Cursor, and Claude Desktop, and for [AppleScript](/external-api/applescript). A token's own scope cannot raise it. See [External API](/external-api) | | **Local only** | Keeps this connection off iCloud Sync. See [iCloud Sync](/features/icloud-sync) | | Plugin fields | Driver-specific options, such as MongoDB's `replicaSet` | A pre-connect script never runs unprompted. A **Pre-Connect Script** alert shows the script itself and waits for **Run Script**, every time. At launch it is not prompted for at all: a restored window whose connection carries a script waits with a **Connect** button. +## Appearance + +**Color** marks the connection in the connection list and its window. **Tags** and **Group** decide where it sits in the welcome window's tree. + ## Startup commands Statements split on semicolons and newlines and run in order on the connection that just opened, after every connect including an automatic reconnect. They all run on one connection, so write one dialect. @@ -78,7 +97,7 @@ Every active connection is pinged every 30 seconds, skipping the ping while one SQLite, DuckDB, Beancount, Snowflake, and Teradata are not monitored. -## Which drivers get which panes +## Which drivers get which transports | Database | Default port | SSH tunnel | SSL/TLS | Cloudflare Tunnel | Cloud SQL Proxy | SOCKS Proxy | Tunnel Command | |----------|-------------|-----------|---------|-------------------|-----------------|-------------|----------------| @@ -109,4 +128,4 @@ SQLite, DuckDB, Beancount, Snowflake, and Teradata are not monitored. | [Cloudflare D1](/databases/cloudflare-d1) | Cloud API | No | No | No | No | No | No | | [libSQL / Turso](/databases/libsql) | URL | No | No | No | No | No | No | -A driver with no SSL/TLS pane is either a local file or an HTTPS API that manages its own encryption. [SSL/TLS](/connections/ssl) has the per-driver defaults. +SQLite is the one driver offering **Remote Database File**; it reaches an SSH server without forwarding a port. A driver with no SSL/TLS column is either a local file or an HTTPS API that manages its own encryption. [SSL/TLS](/connections/ssl) has the per-driver defaults. diff --git a/docs/connections/index.mdx b/docs/connections/index.mdx index 858b239a63..c05fa02053 100644 --- a/docs/connections/index.mdx +++ b/docs/connections/index.mdx @@ -32,7 +32,7 @@ Press `Cmd+N` anywhere in the app to open the connection form. **Create Connecti Database type chooser -Every driver's default port, and which of SSH tunnel, SSL/TLS, Cloudflare Tunnel, Cloud SQL Proxy, and SOCKS Proxy it accepts, is in the [connection form reference](/connections/connection-form#which-drivers-get-which-panes). +Every driver's default port, and which of SSH tunnel, SSL/TLS, Cloudflare Tunnel, Cloud SQL Proxy, and SOCKS Proxy it accepts, is in the [connection form reference](/connections/connection-form#which-drivers-get-which-transports). ### Import from URL @@ -59,9 +59,9 @@ A confirmation alert names the target first. Matching a saved connection on type The **Customization** pane holds a color, tags, and a group. The color tints the toolbar while the connection is open. - - Customization pane - Customization pane + + Color swatches, a tag field and a group picker + Color swatches, a tag field and a group picker diff --git a/docs/connections/remote-database-files.mdx b/docs/connections/remote-database-files.mdx index 5d0cb43a07..cd6c7d00c2 100644 --- a/docs/connections/remote-database-files.mdx +++ b/docs/connections/remote-database-files.mdx @@ -10,8 +10,8 @@ That constraint is the feature, not a gap in it. SQLite's own documentation says ## Set one up - - Create or edit a SQLite connection and select **Remote File**. Switch on **Open a database file on an SSH server**. + + Create or edit a SQLite connection, open the **Network** tab, and set **Connect via** to **Remote Database File**. Fill in **SSH Host**, **SSH Port**, and **SSH User**, then pick an authentication method. Password, private key, SSH agent, keyboard-interactive, jump hosts, and one-time codes all work the way they do for a tunnel, and a saved [SSH profile](/connections/ssh-profiles) supplies all of it at once. diff --git a/docs/connections/socks-proxy.mdx b/docs/connections/socks-proxy.mdx index b2e1a6aeb7..b4ad3b5615 100644 --- a/docs/connections/socks-proxy.mdx +++ b/docs/connections/socks-proxy.mdx @@ -3,11 +3,11 @@ title: SOCKS Proxy description: Route a database connection through a SOCKS5 proxy, with remote DNS (socks5h) so the proxy resolves the database hostname --- -Leave the database's **Host** and **Port** on the General pane exactly as they are. The proxy resolves that name and dials it from its own side. A hostname that exists only inside the private network works, and no DNS query for the database leaves your Mac. +Leave the database's **Host** and **Port** on the General section exactly as they are. The proxy resolves that name and dials it from its own side. A hostname that exists only inside the private network works, and no DNS query for the database leaves your Mac. - - SOCKS Proxy pane - SOCKS Proxy pane + + Proxy server host and port with a username and password below + Proxy server host and port with a username and password below ## How it works @@ -35,8 +35,8 @@ No helper binary is involved: the relay is part of the app. It listens on a free ## Setting up - - Select **SOCKS Proxy** and turn **Enable SOCKS Proxy** on. Only one method at a time: anything else already enabled has a button here to switch it off. + + On the **Network** tab, set **Connect via** to **SOCKS Proxy**. A connection uses one transport, so choosing this one switches off whichever was selected before. **Host** and **Port** under **Proxy Server**, plus **Username** and **Password** if the proxy authenticates. @@ -46,7 +46,7 @@ No helper binary is involved: the relay is part of the app. It listens on a free -The pane appears for the drivers that support SSH tunneling; the [transport matrix](/connections/connection-form#which-drivers-get-which-panes) says which. +It is offered for the drivers that support SSH tunneling; the [transport matrix](/connections/connection-form#which-drivers-get-which-transports) says which. ## Options diff --git a/docs/connections/ssh-profiles.mdx b/docs/connections/ssh-profiles.mdx index bcdb8e54cc..90b1861b98 100644 --- a/docs/connections/ssh-profiles.mdx +++ b/docs/connections/ssh-profiles.mdx @@ -8,7 +8,7 @@ One bastion, many connections. A profile holds the server (host, port, username) ## Create a profile - + **Create New Profile…** sits beside the **Profile** picker. @@ -34,7 +34,7 @@ One bastion, many connections. A profile holds the server (host, port, username) **Edit Profile…** opens the selected profile, and **Delete Profile** is at the bottom of that editor. The password, key passphrase, and TOTP secret are read from the profile at connect time, so correcting one there reaches every connection using it. -Deleting a profile leaves the connections that used it pointing at nothing. Each shows **Selected SSH profile no longer exists.** on its SSH Tunnel pane, with a **Switch to Inline Configuration** button, until it is given a tunnel config again. +Deleting a profile leaves the connections that used it pointing at nothing. Each shows **Selected SSH profile no longer exists.** under **SSH Tunnel**, with a **Switch to Inline Configuration** button, until it is given a tunnel config again. ## iCloud Sync diff --git a/docs/connections/ssh-tunneling.mdx b/docs/connections/ssh-tunneling.mdx index fe13299a2a..c7d64e24dc 100644 --- a/docs/connections/ssh-tunneling.mdx +++ b/docs/connections/ssh-tunneling.mdx @@ -3,7 +3,7 @@ title: SSH Tunneling description: Route database connections through an SSH tunnel to reach servers in private networks --- -The database **Host** on the General pane is resolved from the SSH server, not from your Mac. A database on the SSH server itself is therefore `localhost`, not the server's public name, and a database elsewhere on the private network is whatever the SSH server calls it (an RDS endpoint, for instance). +The database **Host** on the General section is resolved from the SSH server, not from your Mac. A database on the SSH server itself is therefore `localhost`, not the server's public name, and a database elsewhere on the private network is whatever the SSH server calls it (an RDS endpoint, for instance). ```mermaid flowchart LR @@ -14,7 +14,7 @@ flowchart LR - In the connection form, open the **SSH Tunnel** pane and switch on **Enable SSH Tunnel**. A connection uses one transport at a time; if a Cloudflare tunnel, Cloud SQL Auth Proxy, or SOCKS proxy is already on, the pane offers a button to switch it off. + In the connection form, open the **Network** tab and set **Connect via** to **SSH Tunnel**. A connection uses one transport, so choosing this one switches off whichever was selected before. Fill in **SSH Host**, **SSH Port** (22 by default), and **SSH User**. With `~/.ssh/config` entries present, a **Config Host** picker appears above the host field instead. @@ -30,14 +30,14 @@ flowchart LR -To share one SSH config across connections, save it with **Save Current as Profile…** or pick an existing one from the **Profile** picker; see [SSH Profiles](/connections/ssh-profiles). To fill the whole pane from a string instead, paste a `scheme+ssh://` URL into the [Import from URL…](/connections/urls#ssh-tunnel-format) sheet. +To share one SSH config across connections, save it with **Save Current as Profile…** or pick an existing one from the **Profile** picker; see [SSH Profiles](/connections/ssh-profiles). To fill the fields from a string instead, paste a `scheme+ssh://` URL into the [Import from URL…](/connections/urls#ssh-tunnel-format) sheet. - SSH Tunnel pane with a saved profile selected - SSH Tunnel pane with a saved profile selected + Network section with SSH Tunnel selected and a saved profile in the Profile picker + Network section with SSH Tunnel selected and a saved profile in the Profile picker -There is no **SSH Tunnel** pane on SQLite, PGlite, libSQL, Beancount, BigQuery, Cloudflare D1, DynamoDB, Elasticsearch, or Snowflake: each is reached over a local file, a loopback socket, or a vendor HTTP API. +**SSH Tunnel** is not offered on SQLite, PGlite, libSQL, Beancount, BigQuery, Cloudflare D1, DynamoDB, Elasticsearch, or Snowflake: each is reached over a local file, a loopback socket, or a vendor HTTP API. ## Authentication methods @@ -90,7 +90,7 @@ With the list left empty and an SSH host that matches a config entry carrying `P ## Forwarding to a unix socket -Some servers listen on a unix socket with no TCP port open at all, a PostgreSQL box set up for `local` connections in `pg_hba.conf` being the usual case. Fill in **Socket Path** on the General pane and the forward targets that socket, the same thing `ssh -L 5434:/var/run/postgresql/.s.PGSQL.5432 server` does by hand. **Host** and **Port** are ignored while a socket path is set. Jump hosts still apply: the hops reach the SSH server, and the socket opens from there. +Some servers listen on a unix socket with no TCP port open at all, a PostgreSQL box set up for `local` connections in `pg_hba.conf` being the usual case. Fill in **Socket Path** under **SSH Tunnel** and the forward targets that socket, the same thing `ssh -L 5434:/var/run/postgresql/.s.PGSQL.5432 server` does by hand. **Host** and **Port** are ignored while a socket path is set. Jump hosts still apply: the hops reach the SSH server, and the socket opens from there. Point at the socket file, not the directory holding it: @@ -126,7 +126,7 @@ The socket path does not exist on the server, or `sshd_config` sets `AllowStream ### "No SSH agent answered on the socket from …" -Nothing is listening where that socket points, and the message names which of the three set it: **Agent Socket** on the SSH Tunnel pane, an `IdentityAgent` line for the host in `~/.ssh/config`, or `SSH_AUTH_SOCK`. Change it in the place the message names. +Nothing is listening where that socket points, and the message names which of the three set it: **Agent Socket** under **SSH Tunnel**, an `IdentityAgent` line for the host in `~/.ssh/config`, or `SSH_AUTH_SOCK`. Change it in the place the message names. `SSH_AUTH_SOCK` is the one that catches people out. An app launched from Finder gets it from launchd, which means the `ssh-agent` macOS runs, whatever a shell profile exports. 1Password and Secretive are reached by naming their own socket: switch **Agent Socket** to **1Password**, or to **Custom Path**. diff --git a/docs/connections/ssl.mdx b/docs/connections/ssl.mdx index f033ce516d..4c06ddb821 100644 --- a/docs/connections/ssl.mdx +++ b/docs/connections/ssl.mdx @@ -15,7 +15,7 @@ Managed databases require TLS, every one of them, from RDS and Cloud SQL to Supa | Verify CA | TLS, and the certificate is validated against the trust store. The hostname is not checked | | Verify Identity | TLS, certificate validated, and the hostname must match the certificate subject | - + SSL mode and certificate settings in the connection form SSL mode and certificate settings in the connection form @@ -35,7 +35,7 @@ SQL Server shows no certificate fields at all. FreeTDS takes no per-connection p ## Per-driver defaults -A new connection starts on the mode that matches the driver's own default, and the pane prints that driver's guidance under the picker where there is any. +A new connection starts on the mode that matches the driver's own default, and the driver's own guidance prints under the picker where there is any. | Driver | Default | What Preferred does | |---|---|---| @@ -44,11 +44,11 @@ A new connection starts on the mode that matches the driver's own default, and t | SQL Server | Preferred | FreeTDS `encryption=request`, falls back to plain | | Teradata | Disabled | Opens a TLS transport, retries on a plain socket if it fails to come up | | MongoDB, Redis, Cassandra, ClickHouse, Elasticsearch, SurrealDB | Disabled | Nothing. No fallback exists, so Preferred forces TLS exactly like Required | -| etcd | Disabled | Nothing. The driver never reads this pane. Set **TLS Mode** in the Advanced fields instead, and see [etcd](/databases/etcd) | +| etcd | Disabled | Nothing. The driver never reads these fields. Set **TLS Mode** on the Options section instead, and see [etcd](/databases/etcd) | | Trino | Disabled | Sends every request over HTTPS with no fallback, again like Required | -| Oracle | Disabled | Connects in plain TCP, so it behaves like Disabled. The pane shows a red warning; use Required to enforce TCPS | -| Snowflake, BigQuery, DynamoDB, Cloudflare D1, libSQL / Turso | Always encrypted | These drivers are HTTPS and manage TLS themselves. No SSL/TLS pane | -| SQLite, DuckDB, Beancount, PGlite | None | Local files or an in-process engine. No SSL/TLS pane | +| Oracle | Disabled | Connects in plain TCP, so it behaves like Disabled. A red warning appears under the picker; use Required to enforce TCPS | +| Snowflake, BigQuery, DynamoDB, Cloudflare D1, libSQL / Turso | Always encrypted | These drivers are HTTPS and manage TLS themselves. No SSL/TLS section | +| SQLite, DuckDB, Beancount, PGlite | None | Local files or an in-process engine. No SSL/TLS section | ## Behind a tunnel diff --git a/docs/connections/tunnel-command.mdx b/docs/connections/tunnel-command.mdx index 214661e066..2526ca2862 100644 --- a/docs/connections/tunnel-command.mdx +++ b/docs/connections/tunnel-command.mdx @@ -6,15 +6,15 @@ description: Reach a database through kubectl port-forward, an AWS SSM session, A `kubectl port-forward` running in a terminal is a window you cannot close for as long as you need the database, and a tab you have to notice when it dies. Move it into the connection and it starts on connect, stops on disconnect, and comes back on its own. - Tunnel Command pane showing the kubectl method with namespace, resource and a Will Run section - Tunnel Command pane showing the kubectl method with namespace, resource and a Will Run section + Tunnel Command selected, with the kubectl method, namespace, resource and a Will Run section + Tunnel Command selected, with the kubectl method, namespace, resource and a Will Run section ## Setting up - - Select **Tunnel Command** and turn **Enable Tunnel Command** on. One method per connection: anything else already enabled has a button here to switch it off. + + On the **Network** tab, set **Connect via** to **Tunnel Command**. A connection uses one transport, so choosing this one switches off whichever was selected before. **kubectl port-forward** and **AWS SSM Session** ask for the two or three things that vary. **Custom Command** takes a command line. @@ -27,7 +27,7 @@ A `kubectl port-forward` running in a terminal is a window you cannot close for -The **Host** and **Port** on the General pane stay the database's own. They are what the forward points at, so a kubectl forward reads the port from there and an SSM session forwards to that host and port from the target instance. +The **Host** and **Port** on the General section stay the database's own. They are what the forward points at, so a kubectl forward reads the port from there and an SSM session forwards to that host and port from the target instance. ## Methods @@ -52,8 +52,8 @@ Three placeholders are substituted before the command runs: | Placeholder | Value | |---|---| | `{port}` | The loopback port allocated for this connection. Required | -| `{host}` | The **Host** field on the General pane | -| `{remotePort}` | The **Port** field on the General pane | +| `{host}` | The **Host** field on the General section | +| `{remotePort}` | The **Port** field on the General section | ```bash ssh -N -L {port}:{host}:{remotePort} bastion.example.com diff --git a/docs/databases/duckdb.mdx b/docs/databases/duckdb.mdx index ddd004b65d..694d6c49d7 100644 --- a/docs/databases/duckdb.mdx +++ b/docs/databases/duckdb.mdx @@ -90,7 +90,7 @@ SELECT * FROM read_parquet('analytics.parquet'); - A DuckDB file takes one writer at a time. A connect against a file another process holds fails, naming that process. Quit the `duckdb` CLI, the Python process or the other TablePro window first. - A data file opened through **Browse…** is read-only, and the file on disk is left byte for byte identical. To change its contents, open a `.duckdb` database and run `CREATE TABLE t AS SELECT * FROM 'data.parquet'`. - Remote (Quack) lists no tables in the sidebar. Write them out by name through the alias. -- No SSL/TLS pane and no SSH tunnel. The engine is in-process; see [SSL/TLS](/connections/ssl). +- No SSL/TLS section and no SSH tunnel. The engine is in-process; see [SSL/TLS](/connections/ssl). - No creating or dropping a database. `ATTACH` is the way to reach a second file. ## Troubleshooting diff --git a/docs/databases/dynamodb.mdx b/docs/databases/dynamodb.mdx index cde6485b99..cb2099479c 100644 --- a/docs/databases/dynamodb.mdx +++ b/docs/databases/dynamodb.mdx @@ -20,7 +20,7 @@ Click **Create Connection…**, select **DynamoDB**, choose an **Auth Method**, ## Connection settings -The form asks for no host, no port and no database, and offers no SSL/TLS pane. Every request is an HTTPS call to the AWS endpoint signed with SigV4, and one connection sees one region's tables. +The form asks for no host, no port and no database, and offers no SSL/TLS section. Every request is an HTTPS call to the AWS endpoint signed with SigV4, and one connection sees one region's tables. | Field | Description | |-------|-------------| diff --git a/docs/databases/libsql.mdx b/docs/databases/libsql.mdx index 2c73470c46..ac3e1be842 100644 --- a/docs/databases/libsql.mdx +++ b/docs/databases/libsql.mdx @@ -67,7 +67,7 @@ libSQL takes SQLite syntax, so browsing, `EXPLAIN QUERY PLAN`, data editing and ## SSL/TLS -There is no SSL/TLS pane. A Remote connection is encrypted when its **Database URL** starts with `https://` and is not when it starts with `http://`, and a local file is not networked at all. See [SSL/TLS](/connections/ssl). +There is no SSL/TLS section. A Remote connection is encrypted when its **Database URL** starts with `https://` and is not when it starts with `http://`, and a local file is not networked at all. See [SSL/TLS](/connections/ssl). ## Limitations diff --git a/docs/databases/mysql.mdx b/docs/databases/mysql.mdx index 1fcc4c422a..110af80b8a 100644 --- a/docs/databases/mysql.mdx +++ b/docs/databases/mysql.mdx @@ -44,7 +44,7 @@ Opening a URL connects directly. See [Connection URL Reference](/connections/url | Docker | `localhost` with the mapped port, password from `MYSQL_ROOT_PASSWORD` | | MAMP Pro | `localhost:8889`, user and password `root` | | AWS RDS / Aurora | Endpoint hostname, password or [AWS IAM](/connections/aws-iam), which signs a fresh 15-minute token on each connect | -| Google Cloud SQL | **Enable Cloud SQL Auth Proxy** and the instance name, in the [Cloud SQL Auth Proxy](/connections/cloud-sql-proxy) pane | +| Google Cloud SQL | **Connect via > Cloud SQL Auth Proxy** and the instance name. See [Cloud SQL Auth Proxy](/connections/cloud-sql-proxy) | | Remote production | [SSH tunnel](/connections/ssh-tunneling) | ## Users & Roles diff --git a/docs/databases/snowflake.mdx b/docs/databases/snowflake.mdx index 2b3f113173..752e86f165 100644 --- a/docs/databases/snowflake.mdx +++ b/docs/databases/snowflake.mdx @@ -29,8 +29,8 @@ Click **Create Connection…**, select **Snowflake**, enter the **Account Identi | **Warehouse** | No | Compute warehouse such as `COMPUTE_WH` | | **Database** | No | Default database. Empty browses all of them | | **Schema** | No | Default schema, `PUBLIC` if empty | -| **Role** | No | Session role, Advanced pane | -| **CLI Connection Name** | No | Section in `~/.snowflake/connections.toml`, Advanced pane | +| **Role** | No | Session role, Options section | +| **CLI Connection Name** | No | Section in `~/.snowflake/connections.toml`, Options section | ## Connection URL @@ -81,7 +81,7 @@ Toolbar pickers move the session to another **Warehouse** or **Role** with `USE ## SSL/TLS -No SSL/TLS pane, and no plaintext option. Every request is HTTPS to the account endpoint on port 443. +No SSL/TLS section, and no plaintext option. Every request is HTTPS to the account endpoint on port 443. ## Limitations diff --git a/docs/databases/surrealdb.mdx b/docs/databases/surrealdb.mdx index c7a861bce4..d8a2b12823 100644 --- a/docs/databases/surrealdb.mdx +++ b/docs/databases/surrealdb.mdx @@ -82,7 +82,7 @@ The Explain button offers **Explain** and **Explain Full**, which rerun the curr **Disabled** is the default and connects over HTTP; every other mode connects over HTTPS. **Preferred** and **Required (skip verify)** accept any certificate, **Verify CA** and **Verify Identity** check it against the system trust store. -**Skip TLS Verification**, a toggle in the Advanced pane, trusts any certificate even when the mode is **Verify CA** or **Verify Identity**. Leave it off outside a development server with a self-signed certificate. +**Skip TLS Verification**, a toggle in the Options section, trusts any certificate even when the mode is **Verify CA** or **Verify Identity**. Leave it off outside a development server with a self-signed certificate. ## Limitations diff --git a/docs/databases/teradata.mdx b/docs/databases/teradata.mdx index 501cc0df73..e16b7fed82 100644 --- a/docs/databases/teradata.mdx +++ b/docs/databases/teradata.mdx @@ -26,8 +26,8 @@ Click **Create Connection…**, select **Teradata**, enter the host, username, a | **Port** | Plain gateway port, default `1025` | | **Database** | Default database after logon. Optional; empty uses your account's default | | **Username / Password** | Teradata credentials | -| **Logon Mechanism** | Advanced pane. `TD2` or `TDNEGO`, uppercase. Default `TD2` | -| **Transaction Mode** | Advanced pane. `DEFAULT`, `ANSI`, or `TERA`, uppercase | +| **Logon Mechanism** | Options section. `TD2` or `TDNEGO`, uppercase. Default `TD2` | +| **Transaction Mode** | Options section. `DEFAULT`, `ANSI`, or `TERA`, uppercase | | **SSL Mode** | **SSL/TLS** pane. **Disabled** by default | Leave **Transaction Mode** on `DEFAULT`, which takes whatever the system is configured for, unless a script needs `ANSI` or `TERA` case sensitivity and commit rules on the session. A system reachable only through a bastion works over an [SSH tunnel](/connections/ssh-tunneling). diff --git a/docs/databases/trino.mdx b/docs/databases/trino.mdx index bcfea9a33f..2167479cb7 100644 --- a/docs/databases/trino.mdx +++ b/docs/databases/trino.mdx @@ -29,7 +29,7 @@ Click **Create Connection…**, select **Trino**, enter the coordinator host and | **Schema** | Default schema. Optional | | **Auth Method** | Authentication pane. `Username & Password` or `JWT Access Token` | | **Password / Access Token** | The credential the chosen method needs: the account password, or the JWT in **Access Token** | -| **Time Zone** | Advanced pane. Optional IANA zone (`America/New_York`) for the session | +| **Time Zone** | Options section. Optional IANA zone (`America/New_York`) for the session | ## Connection URL diff --git a/docs/features/safe-mode.mdx b/docs/features/safe-mode.mdx index 38d0279831..2b20015b30 100644 --- a/docs/features/safe-mode.mdx +++ b/docs/features/safe-mode.mdx @@ -39,7 +39,7 @@ The level appears as a badge in the toolbar, orange for the Alert levels and red Safe Mode level badge and picker in the toolbar -There is no session-only override. A change from the badge writes back to the saved connection, so it holds across tables and tabs, shows up in the Customization pane, and reaches your other Macs when iCloud Sync is on. Editing the level in the form works the same way round and reaches an open connection right away. +There is no session-only override. A change from the badge writes back to the saved connection, so it holds across tables and tabs, shows up on the Options section, and reaches your other Macs when iCloud Sync is on. Editing the level in the form works the same way round and reaches an open connection right away. ## Server read-only is not Safe Mode diff --git a/docs/images/cloud-sql-proxy-pane-dark.png b/docs/images/cloud-sql-proxy-pane-dark.png index 0aceb1e582..a247098ee4 100644 Binary files a/docs/images/cloud-sql-proxy-pane-dark.png and b/docs/images/cloud-sql-proxy-pane-dark.png differ diff --git a/docs/images/cloud-sql-proxy-pane.png b/docs/images/cloud-sql-proxy-pane.png index 85eb9032b4..e45ba02191 100644 Binary files a/docs/images/cloud-sql-proxy-pane.png and b/docs/images/cloud-sql-proxy-pane.png differ diff --git a/docs/images/cloudflare-tunnel-pane-dark.png b/docs/images/cloudflare-tunnel-pane-dark.png index 93d040c23b..45638e4c71 100644 Binary files a/docs/images/cloudflare-tunnel-pane-dark.png and b/docs/images/cloudflare-tunnel-pane-dark.png differ diff --git a/docs/images/cloudflare-tunnel-pane.png b/docs/images/cloudflare-tunnel-pane.png index 467402a696..bce2a95b18 100644 Binary files a/docs/images/cloudflare-tunnel-pane.png and b/docs/images/cloudflare-tunnel-pane.png differ diff --git a/docs/images/cockroachdb-connection-form-dark.png b/docs/images/cockroachdb-connection-form-dark.png index 71abae655f..106eb2be54 100644 Binary files a/docs/images/cockroachdb-connection-form-dark.png and b/docs/images/cockroachdb-connection-form-dark.png differ diff --git a/docs/images/cockroachdb-connection-form.png b/docs/images/cockroachdb-connection-form.png index 87223b12df..95f362fbe5 100644 Binary files a/docs/images/cockroachdb-connection-form.png and b/docs/images/cockroachdb-connection-form.png differ diff --git a/docs/images/connection-customization-dark.png b/docs/images/connection-customization-dark.png index 4be67c5f4b..4fde35d408 100644 Binary files a/docs/images/connection-customization-dark.png and b/docs/images/connection-customization-dark.png differ diff --git a/docs/images/connection-customization.png b/docs/images/connection-customization.png index 5bf92a6d71..d6e5b6a1ec 100644 Binary files a/docs/images/connection-customization.png and b/docs/images/connection-customization.png differ diff --git a/docs/images/connection-form-fields-dark.png b/docs/images/connection-form-fields-dark.png index 34709fa615..4f5fefcf33 100644 Binary files a/docs/images/connection-form-fields-dark.png and b/docs/images/connection-form-fields-dark.png differ diff --git a/docs/images/connection-form-fields.png b/docs/images/connection-form-fields.png index 44b3b914bc..4adfdf8935 100644 Binary files a/docs/images/connection-form-fields.png and b/docs/images/connection-form-fields.png differ diff --git a/docs/images/connection-ssl-settings-dark.png b/docs/images/connection-ssl-settings-dark.png index 81385c807f..9692bee657 100644 Binary files a/docs/images/connection-ssl-settings-dark.png and b/docs/images/connection-ssl-settings-dark.png differ diff --git a/docs/images/connection-ssl-settings.png b/docs/images/connection-ssl-settings.png index 85c4c37085..9441e8599d 100644 Binary files a/docs/images/connection-ssl-settings.png and b/docs/images/connection-ssl-settings.png differ diff --git a/docs/images/pglite-connection-form-dark.png b/docs/images/pglite-connection-form-dark.png index 6569b66e8f..039f364342 100644 Binary files a/docs/images/pglite-connection-form-dark.png and b/docs/images/pglite-connection-form-dark.png differ diff --git a/docs/images/pglite-connection-form.png b/docs/images/pglite-connection-form.png index 1638968be3..907c3dbadb 100644 Binary files a/docs/images/pglite-connection-form.png and b/docs/images/pglite-connection-form.png differ diff --git a/docs/images/postgresql-connection-form-dark.png b/docs/images/postgresql-connection-form-dark.png index 47ac8e3cc3..4e67390d54 100644 Binary files a/docs/images/postgresql-connection-form-dark.png and b/docs/images/postgresql-connection-form-dark.png differ diff --git a/docs/images/postgresql-connection-form.png b/docs/images/postgresql-connection-form.png index 16edccfa64..bc74a920d7 100644 Binary files a/docs/images/postgresql-connection-form.png and b/docs/images/postgresql-connection-form.png differ diff --git a/docs/images/socks-proxy-pane-dark.png b/docs/images/socks-proxy-pane-dark.png index 7c73e198a2..ff083a8513 100644 Binary files a/docs/images/socks-proxy-pane-dark.png and b/docs/images/socks-proxy-pane-dark.png differ diff --git a/docs/images/socks-proxy-pane.png b/docs/images/socks-proxy-pane.png index 150e93bb3c..c926cb3965 100644 Binary files a/docs/images/socks-proxy-pane.png and b/docs/images/socks-proxy-pane.png differ diff --git a/docs/images/sqlite-connection-form-dark.png b/docs/images/sqlite-connection-form-dark.png index f0d297d151..02e780ad0d 100644 Binary files a/docs/images/sqlite-connection-form-dark.png and b/docs/images/sqlite-connection-form-dark.png differ diff --git a/docs/images/sqlite-connection-form.png b/docs/images/sqlite-connection-form.png index 677f3daadf..91798dc505 100644 Binary files a/docs/images/sqlite-connection-form.png and b/docs/images/sqlite-connection-form.png differ diff --git a/docs/images/ssh-tunnel-config-dark.png b/docs/images/ssh-tunnel-config-dark.png index 7500f963db..c240902188 100644 Binary files a/docs/images/ssh-tunnel-config-dark.png and b/docs/images/ssh-tunnel-config-dark.png differ diff --git a/docs/images/ssh-tunnel-config.png b/docs/images/ssh-tunnel-config.png index 1a5e6c59c4..9fcf99c308 100644 Binary files a/docs/images/ssh-tunnel-config.png and b/docs/images/ssh-tunnel-config.png differ diff --git a/docs/images/tunnel-command-pane-dark.png b/docs/images/tunnel-command-pane-dark.png index ae569d1ba0..b0d306b4ef 100644 Binary files a/docs/images/tunnel-command-pane-dark.png and b/docs/images/tunnel-command-pane-dark.png differ diff --git a/docs/images/tunnel-command-pane.png b/docs/images/tunnel-command-pane.png index 2a2bbc175a..a38cead0d1 100644 Binary files a/docs/images/tunnel-command-pane.png and b/docs/images/tunnel-command-pane.png differ diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index 9a1ab69434..0ed895c790 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -47,7 +47,7 @@ With no connections saved, the welcome window offers **Try Sample Database**, a /> - Click **Test Connection** in the **Status** row at the bottom of the General pane. It turns into **Connected** with a green checkmark. Now click **Save & Connect** in the toolbar. + Click **Test Connection** on the bar along the bottom. It turns into **Connected** with a green checkmark. Now click **Save & Connect** beside it. Integrations**: 500 rows by default, 10,000 at most. -Per connection, the **AI Policy** picker in the connection form's Advanced pane takes **Use Default**, **Always Allow**, **Ask Each Time**, or **Never**. The app-wide default is **Ask Each Time**, which asks once per connection per chat session before the first send. **Never** blocks the chat panel and external AI tool calls against that connection. +Per connection, the **AI Policy** picker in the connection form's Options section takes **Use Default**, **Always Allow**, **Ask Each Time**, or **Never**. The app-wide default is **Ask Each Time**, which asks once per connection per chat session before the first send. **Never** blocks the chat panel and external AI tool calls against that connection. **Ask Each Time** gates the chat panel only. Inline suggestions check the policy for **Never** and nothing else, so with **Enable inline suggestions while typing** on, the text before your cursor, the whole query, and the schema go to the provider after every typing pause with no prompt. That toggle is off by default.