Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- HIG tech-debt cleanup: three deferred items from earlier phases. (1) ER diagram nodes now scale with the user's system text-size preference: `ERDiagramLayout.typeScale` derives a multiplier from `NSFont.preferredFont(forTextStyle: .body)`, applied to header height, column row height, node width, layout offsets, and Canvas font sizes. At Larger Accessibility Sizes the nodes grow proportionally instead of overflowing fixed pixel-pinned rows. (2) The ER diagram's floating canvas toolbar drops the `.regularMaterial` rounded-rectangle + manual drop shadow and adopts the macOS Maps / Preview Markup pattern: `Capsule()` shape with `.thinMaterial` plus a half-pixel `.quaternary` stroke. The system handles depth and active-window dimming. (3) Connection Form actions (Test, Delete, Cancel, Save, Save & Connect) move out of a custom HStack footer into the native window toolbar via `ToolbarItem` placements (`.navigation` for Test, `.destructiveAction` for Delete, `.cancellationAction` for Cancel, `.secondaryAction` for "Save Only", `.confirmationAction` for the primary action). The body wraps in `NavigationStack` so `.toolbar { }` attaches to the window titlebar, matching Mail compose, AIProviderDetailSheet, and macOS HIG sheet conventions.
- HIG polish (phase 5): nine hero icons (empty / success / error / Pro feature gate states across Onboarding, Feedback, Export Success, Import Success, Import Error, Query Success, Main editor empty state) move from hardcoded `.system(size: 40-64)` to a Dynamic-Type-aware combination of `.font(.largeTitle).imageScale(.large).symbolRenderingMode(.hierarchical)`, so they scale with the user's accessibility text-size setting and gain the canonical SF Symbols depth treatment. Five `.plain`-button-as-link callsites (Fetch All in the status bar, Show All / Hide All in the column visibility popover, Skip in onboarding, Close in the feedback success state) move to `.buttonStyle(.link)` or `.borderless`. Spacing sweep across 8 files normalises 5pt-grid magic numbers (`spacing: 5`, `spacing: 14`, `padding(.horizontal, 5)`) onto Apple's 8pt grid (`spacing: 4`, `spacing: 12`, `padding(.horizontal, 6)`). The Connection Form's Cancel button gains `.keyboardShortcut(.cancelAction)` so Esc dismisses correctly.
- HIG list & window cleanup (phase 4): three small refactors that align inline list affordances with the rest of the codebase. `SlowQueryListView` (the collapsible "Slow Queries" panel in the Server Dashboard) drops a hand-rolled `Button { chevron }` + `ScrollView` + `LazyVStack` and uses a real `DisclosureGroup` + `List`, so VoiceOver gets the disclosure semantics and keyboard handling for free, and the orange-on-red error pill in its header switches to the warning-triangle convention introduced in phase 3. `ColumnVisibilityPopover` (the column show/hide popover) replaces its `ScrollView` + `LazyVStack` with a native `List`, gaining row hover, separators, and the standard list keyboard-navigation it was missing; the "Show All" / "Hide All" buttons in its header switch from `.plain` + manual accent foreground to `.buttonStyle(.link)`. `JSONViewerWindowController` deletes its hand-coded UserDefaults size persistence and uses the `NSWindow.applyAutosaveName` helper that every other imperative window in the project already uses; window position is now remembered too, not just size.
- HIG pattern refactors (phase 3): five callsites migrate off bespoke implementations onto native primitives. `ResultTabBar` (the `.plain`-button strip in the results pane) gains hover states, accent-tint selection, and a `.bar` material background that matches macOS Sequoia inline tab patterns. `ConnectionSwitcherPopover` drops its manual `selectedIndex: Int` plus per-row `isHighlighted` color flipping and uses native `List(selection:)` for keyboard navigation, focus chrome, and scroll-into-view. The Welcome window's `+` and "new group" buttons replace a custom `@State isHovering` background with `.buttonStyle(.bordered).controlSize(.large)`. Eight inline form validation banners (URL parsing, plugin install, pgpass permissions, jump-host test, license activation, etc.) move from raw `.foregroundStyle(.systemRed)` text to a semantic `Label("...", systemImage: "exclamationmark.triangle.fill").foregroundStyle(.systemOrange)` pattern matching Apple's form-warning convention. Four sheets (Create Database, Create Tag, Export Options, Pagination Settings popover) are rebuilt on `Form { Section { ... } }.formStyle(.grouped)` with `LabeledContent` rows instead of hand-laid `VStack` + caption-styled labels + `roundedBorder` TextFields.
Expand Down
14 changes: 11 additions & 3 deletions TablePro/Models/ERDiagram/ERDiagramLayout.swift
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
import AppKit
import Foundation
import os

/// Sugiyama-style layered layout for ER diagrams.
/// Produces node center positions from a graph of tables and FK edges.
enum ERDiagramLayout {
private static let logger = Logger(subsystem: "com.TablePro", category: "ERDiagramLayout")
static let nodeWidth: CGFloat = 220

/// Multiplier derived from the user's system text-size preference.
/// 1.0 at the default (~13pt body), grows with Larger Accessibility Sizes.
static var typeScale: CGFloat {
max(1.0, NSFont.preferredFont(forTextStyle: .body).pointSize / 13.0)
}

static var nodeWidth: CGFloat { 220 * typeScale }
static let horizontalGap: CGFloat = 60
static let verticalGap: CGFloat = 40
static let headerHeight: CGFloat = 36
static let columnRowHeight: CGFloat = 22
static var headerHeight: CGFloat { 36 * typeScale }
static var columnRowHeight: CGFloat { 22 * typeScale }

static func compute(
graph: ERDiagramGraph
Expand Down
4 changes: 2 additions & 2 deletions TablePro/TableProApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -661,11 +661,11 @@ struct TableProApp: App {
restorable: false,
fullScreenable: false,
hideMiniaturizeButton: true,
hideZoomButton: true
hideZoomButton: false
))
}
.windowResizability(.contentMinSize)
.defaultSize(width: 640, height: 500)
.defaultSize(width: 560, height: 560)
.commandsRemoved()

Window("Integrations Activity", id: SceneId.integrationsActivity) {
Expand Down
122 changes: 53 additions & 69 deletions TablePro/Views/Connection/ConnectionFormView+Footer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,88 +8,72 @@
import SwiftUI
import TableProPluginKit

// MARK: - Footer
// MARK: - Toolbar

extension ConnectionFormView {
var footer: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
// Test connection
Button(action: testConnection) {
HStack(spacing: 6) {
if isTesting {
ProgressView()
.controlSize(.small)
} else if testSucceeded {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Color(nsColor: .systemGreen))
} else {
Image(systemName: "antenna.radiowaves.left.and.right")
.foregroundStyle(.secondary)
@ToolbarContentBuilder
var connectionFormToolbar: some ToolbarContent {
if !isNew {
ToolbarItem(placement: .destructiveAction) {
Button(String(localized: "Delete"), role: .destructive) {
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 {
deleteConnection()
}
Text(testSucceeded ? String(localized: "Connected") : String(localized: "Test Connection"))
}
}
.disabled(isTesting || isInstallingPlugin || !isValid)

if !isNew {
Button("Delete", role: .destructive) {
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 {
deleteConnection()
}
}
}
}

Spacer()
}
}

// Cancel
Button("Cancel") {
dismiss()
}
ToolbarItemGroup(placement: .confirmationAction) {
Button(String(localized: "Cancel")) { dismiss() }
.keyboardShortcut(.cancelAction)

if isNew {
Button(String(localized: "Save")) {
saveConnection(connect: false)
}
if isNew {
Button(String(localized: "Save")) { saveConnection(connect: false) }
.disabled(isInstallingPlugin || !isValid)
}
}

Button(isNew ? String(localized: "Save & Connect") : String(localized: "Save")) {
saveConnection(connect: isNew)
}
.keyboardShortcut(.return)
.buttonStyle(.borderedProminent)
.disabled(isInstallingPlugin || !isValid)
Button(isNew ? String(localized: "Save & Connect") : String(localized: "Save")) {
saveConnection(connect: isNew)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
.disabled(isInstallingPlugin || !isValid)
}
.background(Color(nsColor: .windowBackgroundColor))
.onExitCommand {
dismiss()
}

// MARK: - Test Connection Strip

var testConnectionStrip: some View {
HStack(spacing: 8) {
Button(action: testConnection) {
HStack(spacing: 6) {
if isTesting {
ProgressView().controlSize(.small)
} else if testSucceeded {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Color(nsColor: .systemGreen))
} else {
Image(systemName: "antenna.radiowaves.left.and.right")
.foregroundStyle(.secondary)
}
Text(testSucceeded ? String(localized: "Connected") : String(localized: "Test Connection"))
}
}
.controlSize(.small)
.disabled(isTesting || isInstallingPlugin || !isValid)

Spacer()
}
.onChange(of: host) { _, _ in testSucceeded = false }
.onChange(of: port) { _, _ in testSucceeded = false }
.onChange(of: username) { _, _ in testSucceeded = false }
.onChange(of: password) { _, _ in testSucceeded = false }
.onChange(of: database) { _, _ in testSucceeded = false }
.onChange(of: type) { _, _ in testSucceeded = false }
.onChange(of: sshState.enabled) { _, _ in testSucceeded = false }
.onChange(of: sshState.host) { _, _ in testSucceeded = false }
.onChange(of: sshState.port) { _, _ in testSucceeded = false }
.onChange(of: sshState.username) { _, _ in testSucceeded = false }
.onChange(of: sshState.authMethod) { _, _ in testSucceeded = false }
.onChange(of: sslMode) { _, _ in testSucceeded = false }
.onChange(of: additionalFieldValues) { _, _ in testSucceeded = false }
.padding(.horizontal, 16)
.padding(.vertical, 8)
}

// MARK: - Import from URL Sheet
Expand Down
58 changes: 37 additions & 21 deletions TablePro/Views/Connection/ConnectionFormView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -152,39 +152,43 @@ struct ConnectionFormView: View {
// MARK: - Body

var body: some View {
VStack(spacing: 0) {
// Tab picker
Picker("", selection: $selectedTab) {
ForEach(visibleTabs, id: \.rawValue) { tab in
Text(tab.rawValue).tag(tab)
NavigationStack {
VStack(spacing: 0) {
// Tab picker
Picker("", selection: $selectedTab) {
ForEach(visibleTabs, id: \.rawValue) { tab in
Text(tab.rawValue).tag(tab)
}
}
}
.pickerStyle(.segmented)
.labelsHidden()
.padding(.horizontal, 20)
.padding(.vertical, 8)
.pickerStyle(.segmented)
.labelsHidden()
.padding(.horizontal, 20)
.padding(.vertical, 8)

clipboardConnectionBannerView
.animation(.easeInOut(duration: 0.18), value: clipboardCandidate)
clipboardConnectionBannerView
.animation(.easeInOut(duration: 0.18), value: clipboardCandidate)

// Tab form content
tabForm
// Tab form content
tabForm

Divider()
Divider()

footer
testConnectionStrip
}
.navigationTitle(
isNew ? String(localized: "New Connection") : String(localized: "Edit Connection")
)
.toolbar { connectionFormToolbar }
}
.frame(width: 480)
.frame(minHeight: 520, idealHeight: 520)
.navigationTitle(
isNew ? String(localized: "New Connection") : String(localized: "Edit Connection")
)
.frame(minWidth: 480, idealWidth: 560, maxWidth: 720)
.frame(minHeight: 520, idealHeight: 560)
.onAppear {
loadConnectionData()
loadSSHConfig()
detectClipboardConnectionStringIfNeeded()
}
.onChange(of: type) { _, newType in
testSucceeded = false
if hasLoadedData {
port = String(newType.defaultPort)
additionalFieldValues = [:]
Expand All @@ -200,6 +204,18 @@ struct ConnectionFormView: View {
isInstallingPlugin = false
pluginInstallError = nil
}
.onChange(of: host) { _, _ in testSucceeded = false }
.onChange(of: port) { _, _ in testSucceeded = false }
.onChange(of: username) { _, _ in testSucceeded = false }
.onChange(of: password) { _, _ in testSucceeded = false }
.onChange(of: database) { _, _ in testSucceeded = false }
.onChange(of: sshState.enabled) { _, _ in testSucceeded = false }
.onChange(of: sshState.host) { _, _ in testSucceeded = false }
.onChange(of: sshState.port) { _, _ in testSucceeded = false }
.onChange(of: sshState.username) { _, _ in testSucceeded = false }
.onChange(of: sshState.authMethod) { _, _ in testSucceeded = false }
.onChange(of: sslMode) { _, _ in testSucceeded = false }
.onChange(of: additionalFieldValues) { _, _ in testSucceeded = false }
.pluginInstallPrompt(connection: $pluginInstallConnection) { connection in
connectAfterInstall(connection)
}
Expand Down
23 changes: 12 additions & 11 deletions TablePro/Views/ERDiagram/ERDiagramNodeRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import SwiftUI

/// Renders table nodes imperatively on a Canvas GraphicsContext.
enum ERDiagramNodeRenderer {
private static let headerTextXOffset: CGFloat = 28
private static let iconXOffset: CGFloat = 10
private static let badgeXOffset: CGFloat = 14
private static let columnNameXOffset: CGFloat = 24
private static let typeRightMargin: CGFloat = 8
private static var headerTextXOffset: CGFloat { 28 * ERDiagramLayout.typeScale }
private static var iconXOffset: CGFloat { 10 * ERDiagramLayout.typeScale }
private static var badgeXOffset: CGFloat { 14 * ERDiagramLayout.typeScale }
private static var columnNameXOffset: CGFloat { 24 * ERDiagramLayout.typeScale }
private static var typeRightMargin: CGFloat { 8 * ERDiagramLayout.typeScale }
private static let maxTableNameChars = 24
private static let maxTypeChars = 18

Expand All @@ -16,6 +16,7 @@ enum ERDiagramNodeRenderer {
rect: CGRect,
isSelected: Bool
) {
let scale = ERDiagramLayout.typeScale
let cornerRadius: CGFloat = 6
let roundedRect = RoundedRectangle(cornerRadius: cornerRadius)
let path = Path(roundedRect: rect, cornerRadius: cornerRadius)
Expand Down Expand Up @@ -43,7 +44,7 @@ enum ERDiagramNodeRenderer {
? String(node.tableName.prefix(maxTableNameChars)) + "\u{2026}"
: node.tableName
let headerText = Text(displayName)
.font(.system(size: 12, weight: .semibold, design: .monospaced))
.font(.system(size: 12 * scale, weight: .semibold, design: .monospaced))
context.draw(
context.resolve(headerText),
at: CGPoint(x: rect.minX + headerTextXOffset, y: rect.minY + headerHeight / 2),
Expand All @@ -52,7 +53,7 @@ enum ERDiagramNodeRenderer {

// Table icon
let iconText = Text(Image(systemName: "tablecells"))
.font(.system(size: 10))
.font(.system(size: 10 * scale))
.foregroundStyle(.secondary)
context.draw(
context.resolve(iconText),
Expand All @@ -76,15 +77,15 @@ enum ERDiagramNodeRenderer {

// PK/FK badge
if col.isPrimaryKey {
let badge = Text(Image(systemName: "key.fill")).font(.system(size: 8)).foregroundStyle(Color(nsColor: .systemYellow))
let badge = Text(Image(systemName: "key.fill")).font(.system(size: 8 * scale)).foregroundStyle(Color(nsColor: .systemYellow))
clipped.draw(clipped.resolve(badge), at: CGPoint(x: rect.minX + badgeXOffset, y: rowY), anchor: .center)
} else if col.isForeignKey {
let badge = Text(Image(systemName: "link")).font(.system(size: 8)).foregroundStyle(Color(nsColor: .systemBlue))
let badge = Text(Image(systemName: "link")).font(.system(size: 8 * scale)).foregroundStyle(Color(nsColor: .systemBlue))
clipped.draw(clipped.resolve(badge), at: CGPoint(x: rect.minX + badgeXOffset, y: rowY), anchor: .center)
}

// Column name
let nameText = Text(col.name).font(.system(size: 11, design: .monospaced))
let nameText = Text(col.name).font(.system(size: 11 * scale, design: .monospaced))
clipped.draw(
clipped.resolve(nameText),
at: CGPoint(x: rect.minX + columnNameXOffset, y: rowY),
Expand All @@ -96,7 +97,7 @@ enum ERDiagramNodeRenderer {
? String(col.dataType.prefix(maxTypeChars)) + "\u{2026}"
: col.dataType
let typeText = Text(displayType)
.font(.system(size: 10, design: .monospaced))
.font(.system(size: 10 * scale, design: .monospaced))
.foregroundStyle(.secondary)
clipped.draw(
clipped.resolve(typeText),
Expand Down
7 changes: 3 additions & 4 deletions TablePro/Views/ERDiagram/ERDiagramToolbar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,9 @@ struct ERDiagramToolbar: View {
.accessibilityLabel(String(localized: "Export as PNG"))
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 8))
.shadow(color: .black.opacity(0.1), radius: 4, y: 2)
.padding(.vertical, 6)
.background(.thinMaterial, in: Capsule())
.overlay(Capsule().strokeBorder(.quaternary, lineWidth: 0.5))
.padding(12)
}
}
Loading