diff --git a/desktop/src/components/connections/DetailedConnectionForm.tsx b/desktop/src/components/connections/DetailedConnectionForm.tsx index 53d5f00..9a54b88 100644 --- a/desktop/src/components/connections/DetailedConnectionForm.tsx +++ b/desktop/src/components/connections/DetailedConnectionForm.tsx @@ -8,7 +8,7 @@ import type { ConnectionFormData } from "./connectionFormData"; export interface DetailedConnectionFormProps { form: ConnectionFormData; onChange: (updates: Partial) => void; - managedPreset?: "supabase" | "neon" | null; + managedPreset?: "supabase" | "neon" | "planetscale" | null; } export function DetailedConnectionForm({ form, onChange, managedPreset }: DetailedConnectionFormProps) { diff --git a/desktop/src/components/connections/GeneralTab.tsx b/desktop/src/components/connections/GeneralTab.tsx index c5f1aa5..3dc4a0d 100644 --- a/desktop/src/components/connections/GeneralTab.tsx +++ b/desktop/src/components/connections/GeneralTab.tsx @@ -7,7 +7,7 @@ import type { ConnectionFormData } from "./connectionFormData"; export interface GeneralTabProps { form: ConnectionFormData; onChange: (updates: Partial) => void; - managedPreset?: "supabase" | "neon" | null; + managedPreset?: "supabase" | "neon" | "planetscale" | null; } const AUTH_OPTIONS = ["User & Password"]; @@ -32,7 +32,7 @@ export function GeneralTab({ form, onChange, managedPreset }: GeneralTabProps) { )} {managedPreset && (

- {managedPreset === "supabase" ? "Supabase" : "NeonDB"} requires SSL — enable it under SSH / SSL. + {managedPreset === "supabase" ? "Supabase" : managedPreset === "planetscale" ? "PlanetScale" : "NeonDB"} requires SSL — enable it under SSH / SSL.

)} diff --git a/desktop/src/components/connections/NewConnectionScreen.tsx b/desktop/src/components/connections/NewConnectionScreen.tsx index 224a4f5..eac2a9b 100644 --- a/desktop/src/components/connections/NewConnectionScreen.tsx +++ b/desktop/src/components/connections/NewConnectionScreen.tsx @@ -74,7 +74,7 @@ export function NewConnectionScreen({ }: NewConnectionScreenProps) { const [stage, setStage] = useState("entry"); const [managedPreset, setManagedPreset] = useState< - "supabase" | "neon" | null + "supabase" | "neon" | "planetscale" | null >(null); const [form, setForm] = useState(() => createEmptyForm( @@ -99,7 +99,7 @@ export function NewConnectionScreen({ const parsed = parseConnectionString(value); if (parsed) { const provider = - parsed.db_type === "postgresql" + parsed.db_type === "postgresql" || parsed.db_type === "mysql" ? detectProviderFromHost(parsed.host) : null; setManagedPreset(provider); @@ -139,7 +139,9 @@ export function NewConnectionScreen({ (id: ProviderId) => { const provider = getProviderById(id)!; setManagedPreset( - provider.isManagedPreset ? (id as "supabase" | "neon") : null, + provider.isManagedPreset + ? (id as "supabase" | "neon" | "planetscale") + : null, ); revealConfigured({ db_type: provider.dbType, @@ -148,6 +150,7 @@ export function NewConnectionScreen({ ? null : getDefaultPort(provider.dbType), ...(provider.dbType === "sqlite" ? { host: "" } : {}), + ...(id === "planetscale" ? { ssl_mode: "verify-full" } : {}), }); }, [revealConfigured], @@ -164,7 +167,7 @@ export function NewConnectionScreen({ const parsed = parseConnectionString(value); if (parsed) { const provider = - parsed.db_type === "postgresql" + parsed.db_type === "postgresql" || parsed.db_type === "mysql" ? detectProviderFromHost(parsed.host) : null; setManagedPreset(provider); diff --git a/desktop/src/components/connections/ProviderSetupGuide.tsx b/desktop/src/components/connections/ProviderSetupGuide.tsx index 1ad52a6..a818b6b 100644 --- a/desktop/src/components/connections/ProviderSetupGuide.tsx +++ b/desktop/src/components/connections/ProviderSetupGuide.tsx @@ -3,12 +3,13 @@ import { ChevronDown } from "lucide-react"; import { SETUP_GUIDES } from "../../lib/providers"; interface ProviderSetupGuideProps { - provider: "supabase" | "neon"; + provider: "supabase" | "neon" | "planetscale"; } -const SSL_NOTE: Record<"supabase" | "neon", string> = { +const SSL_NOTE: Record<"supabase" | "neon" | "planetscale", string> = { supabase: "SSL is required by Supabase.", neon: "Neon requires SSL.", + planetscale: "PlanetScale requires SSL with identity verification.", }; export function ProviderSetupGuide({ provider }: ProviderSetupGuideProps) { diff --git a/desktop/src/components/connections/ProviderTabsGrid.tsx b/desktop/src/components/connections/ProviderTabsGrid.tsx index a4fc9fb..d9b4fdd 100644 --- a/desktop/src/components/connections/ProviderTabsGrid.tsx +++ b/desktop/src/components/connections/ProviderTabsGrid.tsx @@ -24,7 +24,7 @@ export function ProviderTabsGrid({ selectedId, onSelect }: ProviderTabsGridProps }`} > - {p.isManagedPreset ? : } + {p.isManagedPreset ? : } {p.label} {p.isManagedPreset && ( diff --git a/desktop/src/lib/connectionString.test.ts b/desktop/src/lib/connectionString.test.ts index fd8ce99..9f0dc4c 100644 --- a/desktop/src/lib/connectionString.test.ts +++ b/desktop/src/lib/connectionString.test.ts @@ -194,6 +194,19 @@ describe("detectProviderFromHost", () => { }); }); +describe("detectProviderFromHost", () => { + it("detects PlanetScale access hosts", () => { + expect(detectProviderFromHost("xxxx.us-east-2.psdb.cloud")).toBe("planetscale"); + expect(detectProviderFromHost("aws.connect.psdb.cloud")).toBe("planetscale"); + }); + + it("still detects supabase and neon", () => { + expect(detectProviderFromHost("db.abc.supabase.co")).toBe("supabase"); + expect(detectProviderFromHost("ep-1-pooler.eu.aws.neon.tech")).toBe("neon"); + expect(detectProviderFromHost("example.com")).toBeNull(); + }); +}); + describe("buildConnectionUrl", () => { it("builds a PostgreSQL URL with password and sslmode", () => { const url = buildConnectionUrl( diff --git a/desktop/src/lib/connectionString.ts b/desktop/src/lib/connectionString.ts index cd550b1..be2eae9 100644 --- a/desktop/src/lib/connectionString.ts +++ b/desktop/src/lib/connectionString.ts @@ -85,10 +85,13 @@ export function looksLikeConnectionString(input: string): boolean { /** Detect a managed-PostgreSQL provider from a connection host, purely for * UI highlighting. Returns `"supabase"` / `"neon"` / `null`. db_type is * unaffected (both presets persist as `postgresql`). */ -export function detectProviderFromHost(host: string): "supabase" | "neon" | null { +export function detectProviderFromHost( + host: string, +): "supabase" | "neon" | "planetscale" | null { const h = host.toLowerCase(); if (h.endsWith(".supabase.co")) return "supabase"; if (h.endsWith(".neon.tech")) return "neon"; + if (h.endsWith(".psdb.cloud")) return "planetscale"; return null; } diff --git a/desktop/src/lib/dbIcons.tsx b/desktop/src/lib/dbIcons.tsx index 8bd8c21..7e80fbd 100644 --- a/desktop/src/lib/dbIcons.tsx +++ b/desktop/src/lib/dbIcons.tsx @@ -1,4 +1,4 @@ -import { siPostgresql, siMysql, siMariadb, siSqlite, siRedis, siSupabase, siNeon } from "simple-icons"; +import { siPostgresql, siMysql, siMariadb, siSqlite, siRedis, siSupabase, siNeon, siPlanetscale } from "simple-icons"; import type { DbType } from "./types"; // Brand colors from simple-icons @@ -64,16 +64,18 @@ export const DB_ICONS: Record = { // ── Managed-PostgreSQL provider icons (Supabase, NeonDB) ────────── // These are NOT DbType values; connections persist as db_type="postgresql". -type ProviderIconId = "supabase" | "neon"; +type ProviderIconId = "supabase" | "neon" | "planetscale"; const PROVIDER_ICON_DATA: Record = { supabase: { hex: `#${siSupabase.hex}`, path: siSupabase.path }, neon: { hex: `#${siNeon.hex}`, path: siNeon.path }, + planetscale: { hex: `#${siPlanetscale.hex}`, path: siPlanetscale.path }, }; export const PROVIDER_LABELS: Record = { supabase: "Supabase", neon: "NeonDB", + planetscale: "PlanetScale", }; interface ProviderIconProps { diff --git a/desktop/src/lib/providers.test.ts b/desktop/src/lib/providers.test.ts index 2242b28..4811aaa 100644 --- a/desktop/src/lib/providers.test.ts +++ b/desktop/src/lib/providers.test.ts @@ -2,9 +2,9 @@ import { describe, it, expect } from "vitest"; import { PROVIDER_TABS, getProviderById, SETUP_GUIDES } from "./providers"; describe("providers", () => { - it("defines exactly 7 provider tabs in order", () => { + it("defines exactly 8 provider tabs in order", () => { expect(PROVIDER_TABS.map((p) => p.id)).toEqual([ - "postgresql", "mysql", "mariadb", "sqlite", "redis", "supabase", "neon", + "postgresql", "mysql", "mariadb", "sqlite", "redis", "supabase", "neon", "planetscale", ]); }); @@ -40,4 +40,20 @@ describe("providers", () => { expect(SETUP_GUIDES.supabase.sslRequired).toBe(true); expect(SETUP_GUIDES.neon.sslRequired).toBe(true); }); +}); + +describe("PlanetScale preset", () => { + it("registers as a managed MySQL preset persisting db_type mysql", () => { + const tab = PROVIDER_TABS.find((p) => p.id === "planetscale"); + expect(tab).toBeDefined(); + expect(tab!.dbType).toBe("mysql"); + expect(tab!.isManagedPreset).toBe(true); + }); + + it("ships a setup guide requiring SSL with concrete steps", () => { + const guide = SETUP_GUIDES.planetscale; + expect(guide.sslRequired).toBe(true); + expect(guide.steps.length).toBeGreaterThanOrEqual(4); + expect(guide.steps.some((s) => s.detail.includes("psdb.cloud"))).toBe(true); + }); }); \ No newline at end of file diff --git a/desktop/src/lib/providers.ts b/desktop/src/lib/providers.ts index 6789753..015b6e6 100644 --- a/desktop/src/lib/providers.ts +++ b/desktop/src/lib/providers.ts @@ -1,6 +1,6 @@ import type { DbType } from "./types"; -export type ProviderId = "postgresql" | "mysql" | "mariadb" | "sqlite" | "redis" | "supabase" | "neon"; +export type ProviderId = "postgresql" | "mysql" | "mariadb" | "sqlite" | "redis" | "supabase" | "neon" | "planetscale"; export interface ProviderTab { id: ProviderId; @@ -20,6 +20,7 @@ export const PROVIDER_TABS: ProviderTab[] = [ { id: "redis", label: "Redis", dbType: "redis", isManagedPreset: false }, { id: "supabase", label: "Supabase", dbType: "postgresql", isManagedPreset: true }, { id: "neon", label: "NeonDB", dbType: "postgresql", isManagedPreset: true }, + { id: "planetscale", label: "PlanetScale", dbType: "mysql", isManagedPreset: true }, ]; export function getProviderById(id: string): ProviderTab | undefined { @@ -40,7 +41,7 @@ export interface SetupGuide { /** Static, researched setup instructions. No network calls at runtime. * Content reviewed against the official docs (fetched 2026-08-04, see spec §9). */ -export const SETUP_GUIDES: Record<"supabase" | "neon", SetupGuide> = { +export const SETUP_GUIDES: Record<"supabase" | "neon" | "planetscale", SetupGuide> = { supabase: { blurb: "Managed PostgreSQL with a pooled connection string.", sslRequired: true, @@ -66,6 +67,17 @@ export const SETUP_GUIDES: Record<"supabase" | "neon", SetupGuide> = { }, ], }, + planetscale: { + blurb: "Managed MySQL (Vitess) with database branching.", + sslRequired: true, + steps: [ + { title: "Create a branch password", detail: "In the PlanetScale dashboard open your database → Connect → pick a branch → Create password. The password (pscale_pw_…) is shown ONCE and works only for that branch." }, + { title: "Copy the credentials", detail: "Note the Access Host URL (e.g. aws.connect.psdb.cloud or xxxx.us-east-2.psdb.cloud), the username, the password, and your database name." }, + { title: "Fill Host / Port / Database", detail: "Host is the access host, port is 3306, Database is your database name. Save stores the password in your OS keychain." }, + { title: "SSL is mandatory", detail: "Gridline pre-selects Verify Full — PlanetScale requires TLS with identity verification." }, + { title: "Vitess caveats", detail: "Passwords are branch-scoped; deleting one disconnects its clients within 5 minutes. With Safe Migrations ON, schema changes go through deploy requests, so the changes queue may be blocked on production branches. Foreign keys follow your database's foreign_key_constraint setting." }, + ], + }, neon: { blurb: "Serverless Postgres with pooled or direct endpoints.", sslRequired: true,