Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
8fb76e6
feat(explain): visualize MySQL tree plans
sophiathedev Aug 14, 2026
651fdb1
feat(explain): add trackpad zoom
sophiathedev Aug 14, 2026
0f9f5fa
fix(explain): show plan cost, align diagram rows, and redraw on a new…
datlechin Aug 15, 2026
765c177
feat(plugins): let an EXPLAIN variant declare its plan format
datlechin Aug 15, 2026
2b5a1e0
feat(plugins): tag EXPLAIN variants with the plan format they return
datlechin Aug 15, 2026
48969eb
refactor(explain): resolve plan parsers by output format instead of d…
datlechin Aug 15, 2026
4d854f9
fix(explain): run every EXPLAIN through one gated, fenced execution path
datlechin Aug 15, 2026
21d57ef
refactor(explain): define plan severity and labels once for both plan…
datlechin Aug 15, 2026
7b7ff63
feat(components): add a magnifiable canvas backed by NSScrollView
datlechin Aug 15, 2026
957caaa
refactor(explain): rebuild the plan diagram on the magnifiable canvas
datlechin Aug 15, 2026
d7e96f5
refactor(explain): rebuild the plan tree on NSOutlineView with real c…
datlechin Aug 15, 2026
11ead92
feat(explain): show a query plan as a result tab alongside query results
datlechin Aug 15, 2026
537eceb
refactor(er-diagram): adopt the shared magnifiable canvas
datlechin Aug 15, 2026
489ac60
docs(explain): document the rebuilt query plan viewer
datlechin Aug 15, 2026
95620eb
test(explain): add UI automation for the query plan result tab
datlechin Aug 15, 2026
2bff7ac
fix(explain): gate stale plan results and keep tabular EXPLAIN in the…
datlechin Aug 15, 2026
4b8be72
fix(explain): route the plan raw font size through the preferences re…
datlechin Aug 15, 2026
961b668
fix(explain): keep the plan outline's root selection on first load
datlechin Aug 15, 2026
e32c995
Merge remote-tracking branch 'origin/main' into feat/query-plan-rewrite
datlechin Aug 15, 2026
08cda7e
docs(explain): refresh the plan viewer screenshots and add the missin…
datlechin Aug 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- MySQL `EXPLAIN FORMAT=TREE` and `EXPLAIN ANALYZE` output now renders as a visual plan diagram or tree instead of raw text only.
- A query plan now opens as a result tab next to your query results, so you can switch back to the data without re-running the query, and pin a plan to keep it.
- EXPLAIN plan diagrams zoom with a trackpad pinch, a two-finger double tap, or Cmd and scroll, and the controls gained fit-to-window. Plans can be copied or exported as a PNG.
- The plan tree now has resizable, sortable Operation, Cost, Rows and Actual Time columns, arrow-key navigation, a right-click menu to copy a step, and a detail panel you can resize.
- PGlite, Cloudflare D1, libSQL and Turso query plans now render as a diagram and tree instead of raw text.
- Plan steps show a cost badge whose shape and colour escalate together, so cost reads without relying on colour.
- The ER diagram now uses the same zoom and scrolling as the plan diagram, gaining two-finger double tap to zoom, Cmd and scroll, and standard scrollers.
- PostgreSQL array columns of a simple type, including arrays of an enum, get a list editor in the data grid. One row per element, with reordering, add and remove, and NULL per element. An empty array and a NULL column stay separate values. Enum arrays pick from the labels the type declares. Arrays of `jsonb`, `bytea` or composite types, and multi-dimensional values, keep the plain text editor.

### Fixed

- Running EXPLAIN from the toolbar now asks for confirmation when Safe Mode requires it. It previously skipped that check on every database that offers an EXPLAIN variant, even though `EXPLAIN ANALYZE` runs the query.
- The Stop button can now cancel a running `EXPLAIN ANALYZE`.
- An EXPLAIN that fails now reports the error in the usual place instead of showing it where the plan should be.
- Clear Query now clears the query plan too, instead of leaving a stale plan on screen.
- EXPLAIN runs started from the toolbar are now recorded in Query History.
- A query plan that cannot be read as a tree now says so and shows the raw output, instead of leaving an empty pane.
- EXPLAIN plan diagrams line every node of the same depth up on one row, so a tall box no longer pushes its children up into itself.
- Running a second EXPLAIN in the same tab redraws the diagram instead of leaving the previous plan on screen.
- Plans that report a cost per node but no startup cost, such as MySQL's, now show that cost in the diagram and the tree.
- Nodes in a plan whose root reports no cost are no longer all painted red.
- PostgreSQL enum columns whose type lives in another schema now show their values instead of a plain text box.

- Select several databases or schemas in the sidebar tree and act on them at once: drop, refresh, copy names, or export. Shift-click and Cmd-click extend the selection.
Expand Down
10 changes: 5 additions & 5 deletions Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,11 @@ final class ClickHousePlugin: NSObject, TableProPlugin, DriverPlugin {

static let isDownloadable = true
static let explainVariants: [ExplainVariant] = [
ExplainVariant(id: "plan", label: "Plan", sqlPrefix: "EXPLAIN"),
ExplainVariant(id: "pipeline", label: "Pipeline", sqlPrefix: "EXPLAIN PIPELINE"),
ExplainVariant(id: "ast", label: "AST", sqlPrefix: "EXPLAIN AST"),
ExplainVariant(id: "syntax", label: "Syntax", sqlPrefix: "EXPLAIN SYNTAX"),
ExplainVariant(id: "estimate", label: "Estimate", sqlPrefix: "EXPLAIN ESTIMATE"),
ExplainVariant(id: "plan", label: "Plan", sqlPrefix: "EXPLAIN", format: .indentedText),
ExplainVariant(id: "pipeline", label: "Pipeline", sqlPrefix: "EXPLAIN PIPELINE", format: .indentedText),
ExplainVariant(id: "ast", label: "AST", sqlPrefix: "EXPLAIN AST", format: .indentedText),
ExplainVariant(id: "syntax", label: "Syntax", sqlPrefix: "EXPLAIN SYNTAX", format: .indentedText),
ExplainVariant(id: "estimate", label: "Estimate", sqlPrefix: "EXPLAIN ESTIMATE", format: .indentedText),
]
static let brandColorHex = "#FFD100"
static let postConnectActions: [PostConnectAction] = [.selectDatabaseFromLastSession]
Expand Down
4 changes: 3 additions & 1 deletion Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ final class CloudflareD1Plugin: NSObject, TableProPlugin, DriverPlugin {
static let urlSchemes: [String] = ["d1"]

static let explainVariants: [ExplainVariant] = [
ExplainVariant(id: "plan", label: "Query Plan", sqlPrefix: "EXPLAIN QUERY PLAN")
ExplainVariant(
id: "plan", label: "Query Plan", sqlPrefix: "EXPLAIN QUERY PLAN", format: .sqliteQueryPlan
)
]

static let structureColumnFields: [StructureColumnField] = [.name, .type, .nullable, .defaultValue]
Expand Down
4 changes: 3 additions & 1 deletion Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ final class LibSQLPlugin: NSObject, TableProPlugin, DriverPlugin {
static let urlSchemes: [String] = ["libsql"]

static let explainVariants: [ExplainVariant] = [
ExplainVariant(id: "plan", label: "Query Plan", sqlPrefix: "EXPLAIN QUERY PLAN")
ExplainVariant(
id: "plan", label: "Query Plan", sqlPrefix: "EXPLAIN QUERY PLAN", format: .sqliteQueryPlan
)
]

static let structureColumnFields: [StructureColumnField] = [.name, .type, .nullable, .defaultValue]
Expand Down
9 changes: 7 additions & 2 deletions Plugins/MySQLDriverPlugin/MySQLPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,13 @@ final class MySQLPlugin: NSObject, TableProPlugin, DriverPlugin {

static let urlSchemes: [String] = ["mysql"]
static let explainVariants: [ExplainVariant] = [
ExplainVariant(id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN"),
ExplainVariant(id: "explain-json", label: "EXPLAIN (JSON)", sqlPrefix: "EXPLAIN FORMAT=JSON"),
ExplainVariant(id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN", format: .mysqlComposite),
ExplainVariant(
id: "explain-json",
label: "EXPLAIN (JSON)",
sqlPrefix: "EXPLAIN FORMAT=JSON",
format: .mysqlComposite
),
]
static let brandColorHex = "#FF9500"
static let postConnectActions: [PostConnectAction] = [.selectDatabaseFromLastSession]
Expand Down
11 changes: 9 additions & 2 deletions Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,15 @@ final class PostgreSQLPlugin: NSObject, TableProPlugin, DriverPlugin {
static let supportsSchemaSwitching = true
static let postConnectActions: [PostConnectAction] = [.selectSchemaFromLastSession]
static let explainVariants: [ExplainVariant] = [
ExplainVariant(id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN (FORMAT JSON)"),
ExplainVariant(id: "analyze", label: "EXPLAIN ANALYZE", sqlPrefix: "EXPLAIN (ANALYZE, FORMAT JSON)"),
ExplainVariant(
id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN (FORMAT JSON)", format: .postgresJson
),
ExplainVariant(
id: "analyze",
label: "EXPLAIN ANALYZE",
sqlPrefix: "EXPLAIN (ANALYZE, FORMAT JSON)",
format: .postgresJson
),
]
static let databaseGroupingStrategy: GroupingStrategy = .bySchema
static let columnTypesByCategory: [String: [String]] = [
Expand Down
4 changes: 3 additions & 1 deletion Plugins/SQLiteDriverPlugin/SQLitePlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ final class SQLitePlugin: NSObject, TableProPlugin, DriverPlugin {
static let capabilities: [PluginCapability] = [.databaseDriver]

static let explainVariants: [ExplainVariant] = [
ExplainVariant(id: "explain", label: "Explain", sqlPrefix: "EXPLAIN QUERY PLAN")
ExplainVariant(
id: "explain", label: "Explain", sqlPrefix: "EXPLAIN QUERY PLAN", format: .sqliteQueryPlan
)
]

static let databaseTypeId = "SQLite"
Expand Down
22 changes: 22 additions & 0 deletions Plugins/TableProPluginKit/ExplainPlanFormat.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import Foundation

/// The shape of the text a database returns for an EXPLAIN variant. String-based rather than an
/// enum so a plugin can name a format the app does not know yet without a PluginKit release.
public struct ExplainPlanFormat: Hashable, Sendable {
public let rawValue: String

public init(rawValue: String) {
self.rawValue = rawValue
}
}

public extension ExplainPlanFormat {
/// Output the app has no structured parser for. Rendered as text.
static let plainText = ExplainPlanFormat(rawValue: "plainText")

static let postgresJson = ExplainPlanFormat(rawValue: "postgresJson")
static let mysqlComposite = ExplainPlanFormat(rawValue: "mysqlComposite")
static let sqliteQueryPlan = ExplainPlanFormat(rawValue: "sqliteQueryPlan")
static let cockroachText = ExplainPlanFormat(rawValue: "cockroachText")
static let indentedText = ExplainPlanFormat(rawValue: "indentedText")
}
9 changes: 8 additions & 1 deletion Plugins/TableProPluginKit/ExplainVariant.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,17 @@ public struct ExplainVariant: Sendable, Identifiable {
public let id: String
public let label: String
public let sqlPrefix: String
public let format: ExplainPlanFormat

public init(id: String, label: String, sqlPrefix: String) {
public init(id: String, label: String, sqlPrefix: String, format: ExplainPlanFormat = .plainText) {
self.id = id
self.label = label
self.sqlPrefix = sqlPrefix
self.format = format
}

@_disfavoredOverload
public init(id: String, label: String, sqlPrefix: String) {
self.init(id: id, label: label, sqlPrefix: sqlPrefix, format: .plainText)
}
}
23 changes: 15 additions & 8 deletions TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,16 @@ extension QueryExecutionCoordinator {
) {
guard let idx = parent.tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return }

if let planText = ExplainResultRouter.planText(sql: sql, columns: columns, rows: rows) {
if let routed = ExplainResultRouter.route(
sql: sql,
columns: columns,
rows: rows,
databaseType: conn.type,
declaredVariants: conn.type.explainVariants
) {
applyExplainResult(
tabId: tabId,
planText: planText,
routed: routed,
executionTime: executionTime,
rowCount: rows.count,
sql: sql,
Expand Down Expand Up @@ -231,23 +237,24 @@ extension QueryExecutionCoordinator {

private func applyExplainResult(
tabId: UUID,
planText: String,
routed: ExplainResultRouter.RoutedPlan,
executionTime: TimeInterval,
rowCount: Int,
sql: String,
connection conn: DatabaseConnection,
queryParameterValues: [QueryParameter]?
) {
let plan = QueryPlanParserFactory.parser(for: conn.type)?.parse(rawText: planText)

parent.tabManager.mutate(tabId: tabId) { tab in
tab.execution.executionTime = executionTime
tab.execution.rowsAffected = 0
tab.execution.statusMessage = nil
tab.execution.lastExecutedAt = Date()
tab.display.explainText = planText
tab.display.explainPlan = plan
tab.display.explainExecutionTime = executionTime
tab.pagination.resetLoadMore()
tab.display.replaceUnpinnedResults(
with: [ExplainResultSetFactory.make(
rawText: routed.rawText, plan: routed.plan, sql: sql, executionTime: executionTime
)]
)
if tab.display.isResultsCollapsed {
tab.display.isResultsCollapsed = false
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,6 @@ extension QueryExecutionCoordinator {
parent.tabManager.mutate(at: index) { tab in
tab.execution.executionTime = nil
tab.execution.errorMessage = nil
tab.display.explainText = nil
tab.display.explainPlan = nil
}
let tab = parent.tabManager.tabs[index]
parent.toolbarState.setExecuting(true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -518,11 +518,17 @@ extension PluginMetadataRegistry {
requiresAuthentication: true, supportsForeignKeys: false, supportsSchemaEditing: true,
isDownloadable: true, primaryUrlScheme: "clickhouse", parameterStyle: .questionMark,
navigationModel: .standard, explainVariants: [
ExplainVariant(id: "plan", label: "Plan", sqlPrefix: "EXPLAIN"),
ExplainVariant(id: "pipeline", label: "Pipeline", sqlPrefix: "EXPLAIN PIPELINE"),
ExplainVariant(id: "ast", label: "AST", sqlPrefix: "EXPLAIN AST"),
ExplainVariant(id: "syntax", label: "Syntax", sqlPrefix: "EXPLAIN SYNTAX"),
ExplainVariant(id: "estimate", label: "Estimate", sqlPrefix: "EXPLAIN ESTIMATE")
ExplainVariant(id: "plan", label: "Plan", sqlPrefix: "EXPLAIN", format: .indentedText),
ExplainVariant(
id: "pipeline", label: "Pipeline", sqlPrefix: "EXPLAIN PIPELINE", format: .indentedText
),
ExplainVariant(id: "ast", label: "AST", sqlPrefix: "EXPLAIN AST", format: .indentedText),
ExplainVariant(
id: "syntax", label: "Syntax", sqlPrefix: "EXPLAIN SYNTAX", format: .indentedText
),
ExplainVariant(
id: "estimate", label: "Estimate", sqlPrefix: "EXPLAIN ESTIMATE", format: .indentedText
)
],
pathFieldRole: .database,
supportsHealthMonitor: true, urlSchemes: ["clickhouse", "ch"], postConnectActions: [.selectDatabaseFromLastSession],
Expand Down Expand Up @@ -574,7 +580,7 @@ extension PluginMetadataRegistry {
isDownloadable: true, primaryUrlScheme: "duckdb", parameterStyle: .dollar,
navigationModel: .standard,
explainVariants: [
ExplainVariant(id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN"),
ExplainVariant(id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN", format: .indentedText),
],
pathFieldRole: .database,
supportsHealthMonitor: false, urlSchemes: ["duckdb", "quack"], postConnectActions: [],
Expand Down Expand Up @@ -918,7 +924,9 @@ extension PluginMetadataRegistry {
requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: false,
isDownloadable: true, primaryUrlScheme: "d1", parameterStyle: .questionMark,
navigationModel: .standard, explainVariants: [
ExplainVariant(id: "plan", label: "Query Plan", sqlPrefix: "EXPLAIN QUERY PLAN")
ExplainVariant(
id: "plan", label: "Query Plan", sqlPrefix: "EXPLAIN QUERY PLAN", format: .sqliteQueryPlan
)
],
pathFieldRole: .database,
supportsHealthMonitor: true, urlSchemes: ["d1"], postConnectActions: [],
Expand Down Expand Up @@ -976,7 +984,9 @@ extension PluginMetadataRegistry {
requiresAuthentication: false, supportsForeignKeys: true, supportsSchemaEditing: true,
isDownloadable: true, primaryUrlScheme: "libsql", parameterStyle: .questionMark,
navigationModel: .standard, explainVariants: [
ExplainVariant(id: "plan", label: "Query Plan", sqlPrefix: "EXPLAIN QUERY PLAN")
ExplainVariant(
id: "plan", label: "Query Plan", sqlPrefix: "EXPLAIN QUERY PLAN", format: .sqliteQueryPlan
)
],
pathFieldRole: .database,
supportsHealthMonitor: true, urlSchemes: ["libsql"], postConnectActions: [],
Expand Down
11 changes: 9 additions & 2 deletions TablePro/Core/Plugins/PluginMetadataRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -730,8 +730,15 @@ final class PluginMetadataRegistry: @unchecked Sendable {
isDownloadable: false, primaryUrlScheme: "cockroachdb", parameterStyle: .dollar,
navigationModel: .standard,
explainVariants: [
ExplainVariant(id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN"),
ExplainVariant(id: "analyze", label: "EXPLAIN ANALYZE", sqlPrefix: "EXPLAIN ANALYZE"),
ExplainVariant(
id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN", format: .cockroachText
),
ExplainVariant(
id: "analyze",
label: "EXPLAIN ANALYZE",
sqlPrefix: "EXPLAIN ANALYZE",
format: .cockroachText
),
],
pathFieldRole: .database,
supportsHealthMonitor: true, urlSchemes: ["cockroachdb", "cockroach"],
Expand Down
38 changes: 38 additions & 0 deletions TablePro/Core/Services/Query/ExplainFormatResolver.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//
// ExplainFormatResolver.swift
// TablePro
//
// Resolves which plan format a piece of EXPLAIN output is in, for both the Explain action
// (where the variant is known) and a hand-typed statement (where only the SQL is).
//

import Foundation
import TableProPluginKit

enum ExplainFormatResolver {
static func resolve(declared: ExplainPlanFormat, databaseType: DatabaseType) -> ExplainPlanFormat {
guard declared == .plainText else { return declared }
return ExplainPlanFormatDefaults.format(for: databaseType)
}

static func resolve(
sql: String,
databaseType: DatabaseType,
declaredVariants: [ExplainVariant]
) -> ExplainPlanFormat {
let declared = matchingVariant(sql: sql, declaredVariants: declaredVariants)?.format ?? .plainText
return resolve(declared: declared, databaseType: databaseType)
}

/// The declared variant whose SQL prefix the statement starts with. Longest prefix wins so
/// `EXPLAIN FORMAT=JSON ...` matches the JSON variant rather than the bare `EXPLAIN` one.
static func matchingVariant(sql: String, declaredVariants: [ExplainVariant]) -> ExplainVariant? {
let normalized = sql.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalized.isEmpty else { return nil }

return declaredVariants
.filter { !$0.sqlPrefix.isEmpty }
.filter { normalized.range(of: $0.sqlPrefix, options: [.caseInsensitive, .anchored]) != nil }
.max { $0.sqlPrefix.count < $1.sqlPrefix.count }
}
}
30 changes: 30 additions & 0 deletions TablePro/Core/Services/Query/ExplainPlanFormatDefaults.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
//
// ExplainPlanFormatDefaults.swift
// TablePro
//
// The plan format the app assumes for a database type when the driver's declared variant
// does not name one. A plugin built before ExplainVariant carried a format still reports
// .plainText, so without this table its plans would render as raw text until it is rebuilt.
//

import Foundation
import TableProPluginKit

enum ExplainPlanFormatDefaults {
static func format(for databaseType: DatabaseType) -> ExplainPlanFormat {
switch databaseType {
case .postgresql, .redshift, .pglite:
return .postgresJson
case .mysql, .mariadb:
return .mysqlComposite
case .sqlite, .cloudflareD1, .libsql, .turso:
return .sqliteQueryPlan
case .cockroachdb:
return .cockroachText
case .clickhouse, .duckdb:
return .indentedText
default:
return .plainText
}
}
}
34 changes: 34 additions & 0 deletions TablePro/Core/Services/Query/ExplainPlanParserRegistry.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
//
// ExplainPlanParserRegistry.swift
// TablePro
//
// Maps a plan format to the parser that reads it. Keyed by format rather than database type so
// engines that share an output shape share a parser, and a plugin can name a format the app
// does not parse yet without breaking.
//

import Foundation
import TableProPluginKit

enum ExplainPlanParserRegistry {
static func parser(for format: ExplainPlanFormat) -> QueryPlanParser? {
switch format {
case .postgresJson:
return PostgreSQLPlanParser()
case .mysqlComposite:
return MySQLPlanParser()
case .sqliteQueryPlan:
return SQLitePlanParser()
case .cockroachText:
return CockroachDBPlanParser()
case .indentedText:
return IndentedTextPlanParser()
default:
return nil
}
}

static func plan(from rawText: String, format: ExplainPlanFormat) -> QueryPlan? {
parser(for: format)?.parse(rawText: rawText)
}
}
Loading
Loading