This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
These govern every decision — code, architecture, tooling, and process:
- Security first — never introduce vulnerabilities (injection, XSS, OWASP top 10). Validate at system boundaries.
- Native only — use native macOS/iOS components (AppKit, SwiftUI, system frameworks). No cross-platform abstractions, no web views for native UI.
- Clean architecture — proper separation of concerns, protocol-oriented design, dependency injection where appropriate. Every task must consider its impact on architecture and code quality, not just the immediate problem.
- Clean code — self-explanatory naming, early returns over nested conditionals, small focused functions. No comments in the codebase — code must be self-documenting through clear naming and structure.
- Root cause fixes — don't patch symptoms. Diagnose the underlying issue, add logging to debug if needed, then fix the actual cause.
- No hacky solutions — no backward-compatibility shims, no temporary workarounds left in place, no duct tape. If the right fix is harder, do the right fix.
- Testability — every testable code change needs unit/function tests, and UI/user-flow changes should add UI automation where they run deterministically. When tests fail, fix the source code — never adjust tests to match incorrect output.
- Maintainability — follow existing patterns but offer refactors when they improve quality. Extract into extensions when approaching size limits. Group by domain logic.
- Scalability — design for the plugin system's open-ended nature.
DatabaseTypeis a struct, not an enum. All switches needdefault:.
TablePro is a native macOS database client (SwiftUI + AppKit) — a fast, lightweight alternative to TablePlus. macOS 14.0+, Swift 5.9, Universal Binary (arm64 + x86_64).
- Source:
TablePro/—Core/(business logic, services),Views/(UI),Models/(data structures),ViewModels/,Extensions/,Theme/ - Plugins:
Plugins/—.tablepluginbundles +TableProPluginKitshared framework.- Bundled in app: MySQL, PostgreSQL, SQLite, ClickHouse, Redis, CSV, JSON, SQL export, XLSX export, MQL export, SQL import. Shipped only inside the app bundle. Never publish bundled plugins to the registry. Updates ride with the next app release.
- Registry-only: MongoDB, Oracle, DuckDB, MSSQL, Cassandra, Etcd, CloudflareD1, DynamoDB, BigQuery, LibSQL. Distributed via TableProApp/plugins
plugins.json, installed into the user plugins directory.
- C bridges: Each plugin contains its own C bridge module (e.g.,
Plugins/MySQLDriverPlugin/CMariaDB/,Plugins/PostgreSQLDriverPlugin/CLibPQ/) - Static libs:
Libs/— pre-built.afiles.Libs/ios/— xcframeworks for iOS. Both downloaded viascripts/download-libs.sh(not in git) - SPM deps: CodeEditSourceEditor (
mainbranch, tree-sitter editor), Sparkle (2.8.1, auto-update), OracleNIO. Managed via Xcode, noPackage.swift.
# Build (development) — -skipPackagePluginValidation required for SwiftLint plugin in CodeEditSourceEditor
xcodebuild -project TablePro.xcodeproj -scheme TablePro -configuration Debug build -skipPackagePluginValidation
# Clean build
xcodebuild -project TablePro.xcodeproj -scheme TablePro clean
# Build and run
xcodebuild -project TablePro.xcodeproj -scheme TablePro -configuration Debug build -skipPackagePluginValidation && open build/Debug/TablePro.app
# Release builds
scripts/build-release.sh arm64|x86_64|both
# Lint & format
swiftlint lint # Check issues
swiftlint --fix # Auto-fix
swiftformat . # Format code
# Tests
xcodebuild -project TablePro.xcodeproj -scheme TablePro test -skipPackagePluginValidation
xcodebuild -project TablePro.xcodeproj -scheme TablePro test -skipPackagePluginValidation -only-testing:TableProTests/TestClassName
xcodebuild -project TablePro.xcodeproj -scheme TablePro test -skipPackagePluginValidation -only-testing:TableProTests/TestClassName/testMethodName
xcodebuild -project TablePro.xcodeproj -scheme TablePro test -skipPackagePluginValidation -only-testing:TableProUITests
# DMG
scripts/create-dmg.sh
# Static libraries (first-time setup or after lib updates)
scripts/download-libs.sh # Download from GitHub Releases (skips if already present)
scripts/download-libs.sh --force # Re-download and overwriteStatic libs (Libs/*.a) are hosted on the libs-v1 GitHub Release (not in git). When adding or updating a library:
# 1. Update the .a files in Libs/ (build scripts write them there)
# 2. Publish: verifies all OTHER local libs still match the checksums at HEAD,
# regenerates checksums.sha256, uploads the archive. Name every lib you rebuilt.
scripts/publish-libs.sh libmongoc_arm64.a libmongoc_x86_64.a libmongoc_universal.a libmongoc.a
# 3. Commit the updated checksums
git add Libs/checksums.sha256 && git commit -m "build: update static library checksums"Never run shasum -a 256 Libs/*.a > Libs/checksums.sha256 by hand: regenerating from a stale Libs/ reverts other libraries silently (this shipped a broken libmongoc and rolled back DuckDB once). publish-libs.sh exists to make that impossible.
# 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/TableProAll database drivers are .tableplugin bundles loaded at runtime by PluginManager (Core/Plugins/):
- TableProPluginKit (
Plugins/TableProPluginKit/) — shared framework withPluginDatabaseDriver,DriverPlugin,TableProPluginprotocols and transfer types (PluginQueryResult,PluginColumnInfo, etc.). This is the single source of truth; the SwiftPM target atPackages/TableProCore/Sources/TableProPluginKitis a symlink to it, so edit the files underPlugins/TableProPluginKit/only. - PluginDriverAdapter (
Core/Plugins/PluginDriverAdapter.swift) — bridgesPluginDatabaseDriver→DatabaseDriverprotocol - DatabaseDriverFactory (
Core/Database/DatabaseDriver.swift) — looks up plugins viaDatabaseType.pluginTypeId - DatabaseManager (
Core/Database/DatabaseManager.swift) — connection pool, lifecycle, primary interface for views/coordinators - ConnectionHealthMonitor — 30s ping, auto-reconnect with exponential backoff
When adding a new driver: create a new plugin bundle under Plugins/, implement DriverPlugin + PluginDatabaseDriver, add target to pbxproj, add DatabaseType static constant, add case to resolve_plugin_info() in .github/workflows/build-plugin.yml, add row to docs/index.mdx supported databases table, and add CHANGELOG entry. See docs/development/plugin-system/ for details.
When adding a new method to the driver protocol: add to PluginDatabaseDriver (with default implementation), then update PluginDriverAdapter to bridge it to DatabaseDriver. This is an additive, ABI-safe change (see below) and needs no version bump.
PluginKit ABI (resilient): TableProPluginKit is built with BUILD_LIBRARY_FOR_DISTRIBUTION = YES (Swift Library Evolution), so its public ABI is resilient. The Swift runtime instantiates witness tables for already-built plugins and fills any requirement the plugin did not implement from the protocol's default, so a plugin built against an older PluginKit keeps loading under a newer app.
Additive changes are binary-compatible and need NO version bump: adding a requirement to DriverPlugin / PluginDatabaseDriver that has a default implementation, reordering requirements, adding a field to a non-@frozen transfer struct, or removing a requirement that defaulted to nil.
Adding a field to a transfer struct is additive ONLY if every existing public initializer keeps its exact signature. Adding a parameter to an existing public init or function, even with a default value, replaces its mangled symbol and breaks every already-built plugin (this shipped in 0.49.0: PluginQueryResult gained columnMeta: on its init and every registry plugin failed to load with "Bundle failed to load executable"). Add a NEW overload for the new field and keep the old signature; mark the old overload @_disfavoredOverload so new code resolves to the full init while old binaries keep their symbol. Before any PluginKit change run scripts/check-pluginkit-abi.sh (see below) and act on the result: either the diff is additive (verify no symbol disappeared) or it is breaking (bump and re-release).
Bump currentPluginKitVersion (in PluginManager.swift) and TableProPluginKitVersion in every plugin Info.plist ONLY for a breaking change: changing or removing an existing requirement's signature, adding a requirement without a default, adding a case to a @frozen enum, or changing a frozen type's layout. Mark a public enum @frozen only when an exhaustive switch over it forces it (the compiler flags the switch) and its case set is genuinely closed; leave the rest non-frozen so they can gain cases. PluginCapability stays non-frozen with @unknown default because it is a growing capability set, not a closed vocabulary. The driver protocols and transfer structs stay non-frozen so they can grow. The strict version gate in validateBundleVersions still rejects a stale plugin cleanly after a breaking bump (no EXC_BAD_INSTRUCTION).
ABI check (manual): scripts/check-pluginkit-abi.sh [base-ref] builds TableProPluginKit at the current tree and at the base ref with the same toolchain, then diffs their public interfaces. There is no committed baseline, so a Swift version difference between machines never produces a false diff. Run it before merging any change under Plugins/TableProPluginKit/**, comparing against the merge base. A reported diff is a real ABI change: additive needs no bump; breaking needs the version bump above plus release-all-plugins.sh. (Until Library Evolution is on the base too, the base emits no interface and the check passes as a bootstrap.)
Post-ABI-bump checklist (mandatory, breaking bumps only): Bumps are now rare (only the breaking changes listed above). After one, every registry-published plugin must be rebuilt against the new ABI. Run release-all-plugins.sh for the new version BEFORE or WITH the app release, never after, or users on the new app hit noCompatibleBinary until the registry catches up. App auto-update reconciliation handles the user-facing recovery, but the registry has to carry binaries for the new PluginKit version first.
- Commit the bump (updates
PluginManager.swiftand every bundled plugin'sInfo.plist). Bundled plugins ship with the next app release. Do not tag them. - Trigger the bulk re-release:
The workflow runs all registry plugins as a parallel matrix, publishes ZIPs to GitHub Releases, and updates
./scripts/release-all-plugins.sh <newPluginKitVersion>
plugins.json(via.github/scripts/update-registry.py, which appends new binaries and prunes per the--keep-kit-versions 2policy). No manualplugins.jsonediting. - Verify by installing one plugin from the registry on a build with the new PluginKit version.
Binary retention policy: The registry keeps binaries for the two most recent PluginKit versions per plugin (--keep-kit-versions 2). Users on the previous app version can still install plugins; users two or more versions behind hit noCompatibleBinary and need to update the app.
DatabaseType is a string-based struct (not an enum):
- All
switchstatements must includedefault:— the type is open - Use static constants (
.mysql,.postgresql) for known types - Unknown types (from future plugins) are valid — they round-trip through Codable
- Use
DatabaseType.allKnownTypes(notallCases) for the canonical list
SQLEditorTheme— single source of truth for editor colors/fontsTableProEditorTheme— adapter to CodeEdit'sEditorThemeprotocolCompletionEngine— framework-agnostic;SQLCompletionAdapterbridges to CodeEdit'sCodeSuggestionDelegate- Editor tabs use native NSWindow tabs (
NSWindow.tabbingMode = .preferredinTabWindowController); there is no custom tab bar. - Cursor model:
cursorPositions: [CursorPosition](multi-cursor via CodeEditSourceEditor)
- User edits cell →
DataChangeManagerrecords change - User clicks Save →
SQLStatementGeneratorproduces INSERT/UPDATE/DELETE DataChangeUndoManagerprovides undo/redoAnyChangeManagerabstracts over concrete manager for protocol-based usage
These have caused real bugs when violated:
Sync delete ordering: In ConnectionStorage (and all storage classes), SyncChangeTracker.markDeleted() must be called AFTER saveConnections(). The markDeleted call fires postChangeNotification which can trigger a sync. If the file on disk still contains the deleted item when sync runs, it may re-upload the deleted record. Persist first, then notify.
WelcomeViewModel tree rebuild: The welcome screen renders treeItems (grouped/filtered), not connections directly. Every mutation to connections must call rebuildTree() afterward, or the UI won't update.
Tab replacement guard: openTableTab checks for active work (unsaved edits, applied filters, sorting) before replacing the current tab. Tabs with active work open a new native window tab instead. This check runs before the preview tab branch.
Window tab titles: Resolved in TWO places that must stay in sync:
ContentView.init(title resolution chain) — initial title from payloadMainContentView+Setup.swiftupdateWindowTitleAndFileState()— ongoing title updates Missing a case produces a wrong "{Language} Query" title on the first frame.
Schema loading: SQLSchemaProvider (actor) stores an in-flight loadTask: Task<Void, Never>?. Concurrent callers await the same Task instead of firing duplicate fetchTables() queries. Never use a boolean isLoading guard that returns without data — callers need to await the result.
MainContentCoordinator is the central coordinator, split across 7+ extension files in Views/Main/Extensions/ (e.g., +Alerts, +Filtering, +Pagination, +RowOperations). When adding coordinator functionality, add a new extension file rather than growing the main file.
EditorWindow (NSWindow subclass in TabWindowController.swift) overrides performClose: to route Cmd+W through closeTab(). SwiftUI's .commands { Button(...).keyboardShortcut("w") } does NOT replace AppKit's built-in "File > Close" — both fire, and AppKit's wins. The NSWindow subclass is the correct native pattern.
| What | How | Where |
|---|---|---|
| Connection passwords | Keychain | ConnectionStorage |
| User preferences | UserDefaults | AppSettingsStorage / AppSettingsManager |
| Query history | SQLite FTS5 | QueryHistoryStorage |
| Tab state | JSON persistence | TabPersistenceService / TabStateStorage |
| Filter defaults | UserDefaults | FilterSettingsStorage (default column/operator, panel state) |
| Filter presets | UserDefaults | FilterPresetStorage |
| Per-table filters | JSON files | FilterSettingsStorage (one file per connection + database + schema + table; saves the valid working set, each row's enabled flag included) |
| Favorite tables | UserDefaults | FavoriteTablesStorage (per connection + database + schema; iCloud-synced) |
| Tree database filter | UserDefaults | DatabaseTreeFilterStorage (per connection; selected database set, empty = show all; device-local). Live value held in SharedSidebarState. |
Use OSLog for all logging, never print(). When debugging issues, add structured OSLog statements to trace the problem — don't guess.
import os
private static let logger = Logger(subsystem: "com.TablePro", category: "ComponentName")Authoritative sources: .swiftlint.yml and .swiftformat — check those files for the full rule set. Key points:
- No comments — code must be self-explanatory through naming and structure. Never add comments that describe what code does, reference tasks/tickets, or explain callers.
- Early returns — use
guardand earlyreturninstead of nestedif/elseblocks. Flatten control flow. - 4 spaces indentation (never tabs except Makefile/pbxproj)
- 120 char target line length (SwiftFormat); SwiftLint warns at 180, errors at 300
- K&R braces, LF line endings, no semicolons, no trailing commas
- Imports: system frameworks alphabetically → third-party → local, blank line after imports
- Access control: always explicit (
private,internal,public). Specify on extension, not individual members:public extension NSEvent { var semanticKeyCode: KeyCode? { ... } }
- No force unwrapping/casting — use
guard let,if let,as? - Acronyms as words:
JsonEncodernotJSONEncoder(except SDK types)
| Metric | Warning | Error |
|---|---|---|
| File length | 1200 | 1800 |
| Type body | 1100 | 1500 |
| Function body | 160 | 250 |
| Cyclomatic complexity | 40 | 60 |
When approaching limits: extract into TypeName+Category.swift extension files in an Extensions/ subfolder. Group by domain logic, not arbitrary line counts.
These are non-negotiable — never skip them:
-
CHANGELOG.md: Follow Keep a Changelog 1.1.0. Update under
[Unreleased]using the canonical sections:Added,Changed,Deprecated,Removed,Fixed,Security. Do not add a "Fixed" entry for fixing something that is itself still unreleased; fold the fix into the Added or Changed entry instead. Documentation-only changes (docs/,CLAUDE.md,CHANGELOG.mdformatting) do not need a CHANGELOG entry. Each entry is one line, user-facing, with no file paths, class names, or method signatures; reference IDs go in parens at the end:(#1234). -
Localization: Use
String(localized:)for new user-facing strings in computed properties, AppKit code, alerts, and error descriptions. SwiftUI view literals (Text("literal"),Button("literal")) auto-localize. Do NOT localize technical terms (font names, database types, SQL keywords, encoding names). Never useString(localized:)with string interpolation —String(localized: "Preview \(name)")creates a dynamic key that never matches the strings catalog. UseString(format: String(localized: "Preview %@"), name). -
Documentation: Update docs in
docs/(Mintlify-based) when adding/changing features:- New keyboard shortcuts →
docs/features/keyboard-shortcuts.mdx - UI/feature changes → relevant
docs/features/*.mdxpage - Settings changes →
docs/customization/settings.mdx - Database driver changes →
docs/databases/*.mdx
- New keyboard shortcuts →
-
Tests: Every change with testable behavior must include or update unit/function tests. UI and user-flow changes should add or update
TableProUITestsUI automation where the flow runs deterministically; if it can't, note why in the PR description. When tests fail, fix the source code — never adjust tests to match incorrect output. Tests define expected behavior. -
Lint after changes: Run
swiftlint lint --strictto verify compliance. -
Commit messages: Follow Conventional Commits 1.0.0. Single line only, no description body. Format:
<type>(<scope>): <description>. Scope is optional but preferred when the change has a clear domain. Use!after type or scope for breaking changes (e.g.refactor(ai-providers)!: drop OpenAI legacy completion endpoint).Types:
feat,fix,refactor,perf,test,docs,build,ci,chore,style,revert.Canonical scopes (reuse these instead of inventing new ones):
- AI:
ai-chat,ai-providers,mcp,copilot,inline-suggest - App UI:
editor,datagrid,tabs,coordinator,sidebar,connections,connection-form,welcome,settings,toolbar,hig - Infra:
ssh,ios,windows,perf,launch,plugins - Plugins:
plugin-<name>(e.g.plugin-mongodb,plugin-redis,plugin-clickhouse) - Docs and release:
changelog,claude-md,docs,ci,release
Examples:
feat(ai-chat): add /refactor slash command,fix(editor): prevent crash on empty query result,refactor(mcp): migrate pairing store to actor,docs(changelog): adopt Keep a Changelog 1.1.0. - AI:
-
Atomic API changes: When you rename, remove, or change a public type, property, or function signature, update every caller AND every test in the same commit. Do not split a rename from "fix tests for rename" into separate commits; the in-between commit is broken, fails CI, and pollutes
git bisect. If a refactor crosses too many files for one reviewable commit, narrow the change first or stage it behind a typealias the renaming commit removes.
These have caused real production bugs:
- Never use
ForEach($bindable.array) { $item in }on@Observablearrays that can be cleared externally — index-based bindings crash with out-of-bounds when the array shrinks during SwiftUI evaluation. UseForEach(array) { item inwith a manualBindingviabinding(for: item). - Never use
string.counton large strings — O(n) in Swift. Use(string as NSString).lengthfor O(1). - Never use
string.index(string.startIndex, offsetBy:)in loops on bridged NSStrings — O(n) per call. Use(string as NSString).character(at:)for O(1) random access. - Never call
ensureLayout(forCharacterRange:)— defeatsallowsNonContiguousLayout. Let layout manager queries trigger lazy local layout. - SQL dumps can have single lines with millions of characters — cap regex/highlight ranges at 10k chars.
- Tab persistence:
QueryTab.toPersistedTab()truncates queries >500KB to prevent JSON freeze.TabStateStorage.saveLastQuery()skips writes >500KB.
Applies to everything: docs, commit messages, CHANGELOG entries, UI strings, error messages, PR descriptions.
Write like a human developer. Short sentences. Plain words. Say what it does, not how great it is. If a sentence works without a word, drop the word.
No em dashes (—). Anywhere. Use a comma, period, colon, or rewrite the sentence. Hyphens (-) for compound words are fine.
Before any commit that touches user-facing strings, CHANGELOG.md, PR bodies, or files you authored this session, run:
git diff --cached -U0 | grep -nE '—|seamless|robust|comprehensive|intuitive|effortless|streamlined|leverage|elevate|delve|utilize|facilitate'If anything matches, rewrite before committing.
No AI-generated filler. If it sounds like a chatbot wrote it, rewrite it. Banned words: seamless, robust, comprehensive, intuitive, effortless, powerful (as filler), streamlined, leverage, elevate, harness, supercharge, unlock, unleash, dive into, game-changer, empower, delve, utilize, facilitate. No "Absolutely!" / "Ready to dive in?" / "Let's get started!" openers.
Be specific. Numbers, tech names, file paths. "Runs in 200ms" beats "runs fast". "Uses PQexecParams" beats "uses native binding".
GitHub Actions (.github/workflows/build.yml) triggered by v* tags: lint → build arm64 → build x86_64 → release (DMG/ZIP + Sparkle signatures). Release notes auto-extracted from CHANGELOG.md.
Plugin CI (.github/workflows/build-plugin.yml): triggered by plugin-*-v* tags or workflow_dispatch. The dispatch input accepts comma-separated tag:pluginKitVersion pairs; if :pluginKitVersion is omitted, the workflow reads currentPluginKitVersion from PluginManager.swift. Registry update logic lives in .github/scripts/update-registry.py (atomic write, per-binary pluginKitVersion, prune-old policy). Use scripts/release-all-plugins.sh <version> for bulk re-release after an ABI bump.
Plugin tag naming: Tag names must match the CI workflow's resolve_plugin_info() mapping. Notable non-obvious mappings: CloudflareD1DriverPlugin → plugin-cloudflare-d1-v*, EtcdDriverPlugin → plugin-etcd-v*. Check existing tags with git tag -l "plugin-*" before creating new ones.