Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
6ffd9de
feat: add ER diagram visualization with Sugiyama layout (#186)
datlechin Apr 9, 2026
3ba9295
feat: add ER diagram entry points — sidebar context menu and menu bar
datlechin Apr 9, 2026
2ef04f5
fix: defer ER diagram VM creation to onAppear to avoid state mutation…
datlechin Apr 9, 2026
05b0ae2
fix: use fixedSize + PreferenceKey for node height to prevent infinit…
datlechin Apr 9, 2026
61b1283
fix: correct Sugiyama layout to top-to-bottom flow with center-aligne…
datlechin Apr 9, 2026
25a2f0d
fix: use simultaneousGesture for tap to avoid blocking drag gesture
datlechin Apr 9, 2026
48230fb
fix: use highPriorityGesture for drag to override ScrollView scroll
datlechin Apr 9, 2026
3f409fa
fix: replace ScrollView with manual pan canvas for proper drag-to-pan…
datlechin Apr 9, 2026
686649b
feat: add scroll wheel support for canvas panning via NSViewRepresent…
datlechin Apr 9, 2026
0b6bd13
fix: replace NSView overlay with NSEvent monitor for scroll — fixes d…
datlechin Apr 9, 2026
35a6f23
fix: multiply scroll delta for non-precise devices (mouse wheel vs tr…
datlechin Apr 9, 2026
d20b128
perf: add drawingGroup() to rasterize diagram into Metal texture for …
datlechin Apr 9, 2026
c1259b3
perf: rewrite diagram to single Canvas with imperative node drawing f…
datlechin Apr 9, 2026
d64df09
fix: resolve 14 review issues — perf, correctness, and thread safety …
datlechin Apr 9, 2026
565861c
refactor: clean architecture, thread safety, and UX polish for ER dia…
datlechin Apr 9, 2026
c612264
merge: resolve conflicts with main, add ER diagram docs and changelog
datlechin Apr 9, 2026
003d3f9
feat: connection sort order persistence, ER diagram export fix, and docs
datlechin Apr 9, 2026
a83f5a8
fix: remove Libs/ios from git, add to gitignore
datlechin Apr 9, 2026
893cc29
wip
datlechin Apr 9, 2026
80c950e
Update er-diagram.mdx
datlechin Apr 9, 2026
e246ef3
update claude.md
datlechin Apr 9, 2026
7032fe0
update scripts
datlechin Apr 9, 2026
511c0bb
Update er-diagram.mdx
datlechin Apr 9, 2026
f0f3cd9
fix: review issues — @State capture in NSEvent closure, force unwrap,…
datlechin Apr 9, 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,4 @@ build/
# Static libraries (downloaded from GitHub Releases via scripts/download-libs.sh)
Libs/*.a
Libs/.downloaded
Libs/ios/
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Space key toggles FK preview popover on selected cell, rebindable in Settings > Keyboard (#648)
- ER diagram with interactive layout, crow's foot notation, and PNG export (#186)
- Space key toggles FK preview popover (#648)

### Fixed

- Accept SQLAlchemy-style connection URLs with driver hints (e.g., `postgresql+psycopg://`) (#642)
- Accept SQLAlchemy-style connection URLs with driver hints (#642)

## [0.29.0] - 2026-04-09

Expand Down
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ TablePro is a native macOS database client (SwiftUI + AppKit) — a fast, lightw
- **Source**: `TablePro/` — `Core/` (business logic, services), `Views/` (UI), `Models/` (data structures), `ViewModels/`, `Extensions/`, `Theme/`
- **Plugins**: `Plugins/` — `.tableplugin` bundles + `TableProPluginKit` shared framework. Built-in (bundled in app): MySQL, PostgreSQL, SQLite, ClickHouse, MSSQL, Redis, CSV, JSON, SQL export, MQL. Separately distributed via plugin registry: MongoDB, Oracle, DuckDB, Cassandra, Etcd, CloudflareD1, DynamoDB, BigQuery
- **C bridges**: Each plugin contains its own C bridge module (e.g., `Plugins/MySQLDriverPlugin/CMariaDB/`, `Plugins/PostgreSQLDriverPlugin/CLibPQ/`)
- **Static libs**: `Libs/` — pre-built `libmariadb*.a`, `libpq*.a`, etc. Downloaded from GitHub Releases via `scripts/download-libs.sh` (not in git)
- **Static libs**: `Libs/` — pre-built `libmariadb*.a`, `libpq*.a`, etc. `Libs/ios/` — xcframeworks for iOS (Hiredis, LibPQ, MariaDB, OpenSSL, LibSSH2). Both downloaded from GitHub Releases via `scripts/download-libs.sh` (not in git)
- **SPM deps**: CodeEditSourceEditor (`main` branch, tree-sitter editor), Sparkle (2.8.1, auto-update), OracleNIO. Managed via Xcode, no `Package.swift`.

## Build & Development Commands
Expand Down Expand Up @@ -58,6 +58,10 @@ tar czf /tmp/tablepro-libs-v1.tar.gz -C Libs .
gh release upload libs-v1 /tmp/tablepro-libs-v1.tar.gz --clobber --repo TableProApp/TablePro
# 4. Commit the updated checksums
git add Libs/checksums.sha256 && git commit -m "build: update static library checksums"

# iOS xcframeworks (Libs/ios/*.xcframework)
tar czf /tmp/tablepro-libs-ios-v1.tar.gz -C Libs/ios .
gh release upload libs-v1 /tmp/tablepro-libs-ios-v1.tar.gz --clobber --repo TableProApp/TablePro
```

## Architecture
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ public struct PluginForeignKeyInfo: Codable, Sendable {
public let column: String
public let referencedTable: String
public let referencedColumn: String
public let referencedSchema: String?
public let onDelete: String
public let onUpdate: String

Expand All @@ -13,13 +14,15 @@ public struct PluginForeignKeyInfo: Codable, Sendable {
column: String,
referencedTable: String,
referencedColumn: String,
referencedSchema: String? = nil,
onDelete: String = "NO ACTION",
onUpdate: String = "NO ACTION"
) {
self.name = name
self.column = column
self.referencedTable = referencedTable
self.referencedColumn = referencedColumn
self.referencedSchema = referencedSchema
self.onDelete = onDelete
self.onUpdate = onUpdate
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,11 @@ enum SessionStateFactory {
tabMgr.addCreateTableTab(
databaseName: payload.databaseName ?? connection.database
)
case .erDiagram:
tabMgr.addERDiagramTab(
schemaKey: payload.erDiagramSchemaKey ?? payload.databaseName ?? connection.database,
databaseName: payload.databaseName ?? connection.database
)
}
case .newEmptyTab:
tabMgr.addTab(databaseName: payload.databaseName ?? connection.database)
Expand Down
2 changes: 1 addition & 1 deletion TablePro/Core/Services/Query/QueryPlanParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ struct SQLitePlanParser: QueryPlanParser {
nodes.append((id: id, parent: parent, detail: parts[3].trimmingCharacters(in: .whitespaces)))
} else {
// Fallback: treat entire line as a detail node
nodes.append((id: nodes.count, parent: nodes.isEmpty ? -1 : 0, detail: line))
nodes.append((id: nodes.count, parent: -1, detail: line))
}
}

Expand Down
24 changes: 23 additions & 1 deletion TablePro/Core/Storage/ConnectionStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,19 @@ final class ConnectionStorage {
let connections = storedConnections.map { stored in
stored.toConnection()
}

// Migration: assign sortOrder from array position for pre-existing data
if connections.count > 1 && connections.allSatisfy({ $0.sortOrder == 0 }) {
var migrated = connections
for i in migrated.indices { migrated[i].sortOrder = i }
let migratedStored = migrated.map { StoredConnection(from: $0) }
if let data = try? encoder.encode(migratedStored) {
defaults.set(data, forKey: connectionsKey)
}
cachedConnections = migrated
return migrated
}

cachedConnections = connections
return connections
} catch {
Expand Down Expand Up @@ -371,6 +384,9 @@ private struct StoredConnection: Codable {
// Startup commands
let startupCommands: String?

// Sort order for sync
let sortOrder: Int

// TOTP configuration
let totpMode: String
let totpAlgorithm: String
Expand Down Expand Up @@ -440,6 +456,9 @@ private struct StoredConnection: Codable {
// Startup commands
self.startupCommands = connection.startupCommands

// Sort order
self.sortOrder = connection.sortOrder

// Plugin-driven additional fields
self.additionalFields = connection.additionalFields.isEmpty ? nil : connection.additionalFields
}
Expand All @@ -455,7 +474,7 @@ private struct StoredConnection: Codable {
case isReadOnly // Legacy key for migration reading only
case aiPolicy
case mongoAuthSource, mongoReadPreference, mongoWriteConcern, redisDatabase
case mssqlSchema, oracleServiceName, startupCommands
case mssqlSchema, oracleServiceName, startupCommands, sortOrder
case additionalFields
}

Expand Down Expand Up @@ -492,6 +511,7 @@ private struct StoredConnection: Codable {
try container.encodeIfPresent(aiPolicy, forKey: .aiPolicy)
try container.encodeIfPresent(redisDatabase, forKey: .redisDatabase)
try container.encodeIfPresent(startupCommands, forKey: .startupCommands)
try container.encode(sortOrder, forKey: .sortOrder)
try container.encodeIfPresent(additionalFields, forKey: .additionalFields)
}

Expand Down Expand Up @@ -554,6 +574,7 @@ private struct StoredConnection: Codable {
mssqlSchema = try container.decodeIfPresent(String.self, forKey: .mssqlSchema)
oracleServiceName = try container.decodeIfPresent(String.self, forKey: .oracleServiceName)
startupCommands = try container.decodeIfPresent(String.self, forKey: .startupCommands)
sortOrder = try container.decodeIfPresent(Int.self, forKey: .sortOrder) ?? 0
additionalFields = try container.decodeIfPresent([String: String].self, forKey: .additionalFields)
}

Expand Down Expand Up @@ -621,6 +642,7 @@ private struct StoredConnection: Codable {
aiPolicy: parsedAIPolicy,
redisDatabase: redisDatabase,
startupCommands: startupCommands,
sortOrder: sortOrder,
additionalFields: mergedFields
)
}
Expand Down
37 changes: 37 additions & 0 deletions TablePro/Core/Storage/ERDiagramPositionStorage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import Foundation

/// Persists user-arranged table node positions for ER diagrams.
/// Keyed by connection + schema so positions survive across sessions.
@MainActor
final class ERDiagramPositionStorage {
static let shared = ERDiagramPositionStorage()
private let defaults = UserDefaults.standard

private init() {}

private func key(connectionId: UUID, schemaKey: String) -> String {
"com.TablePro.erDiagram.positions.\(connectionId.uuidString).\(schemaKey)"
}

func load(connectionId: UUID, schemaKey: String) -> [String: CGPoint] {
guard let data = defaults.data(forKey: key(connectionId: connectionId, schemaKey: schemaKey)),
let stored = try? JSONDecoder().decode([String: CodablePoint].self, from: data)
else { return [:] }
return stored.mapValues { CGPoint(x: $0.x, y: $0.y) }
}

func save(_ positions: [String: CGPoint], connectionId: UUID, schemaKey: String) {
let stored = positions.mapValues { CodablePoint(x: $0.x, y: $0.y) }
guard let data = try? JSONEncoder().encode(stored) else { return }
defaults.set(data, forKey: key(connectionId: connectionId, schemaKey: schemaKey))
}

func clear(connectionId: UUID, schemaKey: String) {
defaults.removeObject(forKey: key(connectionId: connectionId, schemaKey: schemaKey))
}
}

private struct CodablePoint: Codable {
let x: Double
let y: Double
}
3 changes: 3 additions & 0 deletions TablePro/Core/Sync/SyncRecordMapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ struct SyncRecordMapper {
record["safeModeLevel"] = connection.safeModeLevel.rawValue as CKRecordValue
record["modifiedAtLocal"] = Date() as CKRecordValue
record["schemaVersion"] = schemaVersion as CKRecordValue
record["sortOrder"] = Int64(connection.sortOrder) as CKRecordValue

if let tagId = connection.tagId {
record["tagId"] = tagId.uuidString as CKRecordValue
Expand Down Expand Up @@ -128,6 +129,7 @@ struct SyncRecordMapper {
let aiPolicyRaw = record["aiPolicy"] as? String
let redisDatabase = (record["redisDatabase"] as? Int64).map { Int($0) }
let startupCommands = record["startupCommands"] as? String
let sortOrder = (record["sortOrder"] as? Int64).map { Int($0) } ?? 0
let sshProfileId = (record["sshProfileId"] as? String).flatMap { UUID(uuidString: $0) }

var sshConfig = SSHConfiguration()
Expand Down Expand Up @@ -163,6 +165,7 @@ struct SyncRecordMapper {
aiPolicy: aiPolicyRaw.flatMap { AIConnectionPolicy(rawValue: $0) },
redisDatabase: redisDatabase,
startupCommands: startupCommands,
sortOrder: sortOrder,
additionalFields: additionalFields
)
}
Expand Down
2 changes: 2 additions & 0 deletions TablePro/Models/Connection/ConnectionGroupTree.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ func buildGroupTree(

let groupConnections = connections
.filter { $0.groupId == group.id }
.sorted { $0.sortOrder != $1.sortOrder ? $0.sortOrder < $1.sortOrder : $0.name.localizedStandardCompare($1.name) == .orderedAscending }
for conn in groupConnections {
children.append(.connection(conn))
}
Expand All @@ -67,6 +68,7 @@ func buildGroupTree(
guard let groupId = conn.groupId else { return true }
return !validGroupIds.contains(groupId)
}
.sorted { $0.sortOrder != $1.sortOrder ? $0.sortOrder < $1.sortOrder : $0.name.localizedStandardCompare($1.name) == .orderedAscending }
for conn in ungrouped {
items.append(.connection(conn))
}
Expand Down
3 changes: 3 additions & 0 deletions TablePro/Models/Connection/DatabaseConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ struct DatabaseConnection: Identifiable, Hashable {
var additionalFields: [String: String] = [:]
var redisDatabase: Int?
var startupCommands: String?
var sortOrder: Int

var mongoAuthSource: String? {
get { additionalFields["mongoAuthSource"]?.nilIfEmpty }
Expand Down Expand Up @@ -267,6 +268,7 @@ struct DatabaseConnection: Identifiable, Hashable {
mssqlSchema: String? = nil,
oracleServiceName: String? = nil,
startupCommands: String? = nil,
sortOrder: Int = 0,
additionalFields: [String: String]? = nil
) {
self.id = id
Expand All @@ -286,6 +288,7 @@ struct DatabaseConnection: Identifiable, Hashable {
self.aiPolicy = aiPolicy
self.redisDatabase = redisDatabase
self.startupCommands = startupCommands
self.sortOrder = sortOrder
if let additionalFields {
self.additionalFields = additionalFields
} else {
Expand Down
Loading
Loading