feat: add DSN input mode to connection dialog

Add Fields/DSN toggle in New Connection dialog. DSN mode accepts a
PostgreSQL connection string (postgresql://user:pass@host:port/db?sslmode=...)
and auto-parses it into individual fields. Switching between modes
syncs the data bidirectionally.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 22:19:38 +03:00
parent cebe2a307a
commit 47b040fadf

View File

@@ -21,6 +21,46 @@ import type { ConnectionConfig } from "@/types";
import { ENVIRONMENTS } from "@/lib/environment";
import { Loader2, X } from "lucide-react";
type InputMode = "fields" | "dsn";
function parseDsn(dsn: string): Partial<ConnectionConfig> | null {
try {
const trimmed = dsn.trim();
// Support both postgres:// and postgresql://
const match = trimmed.match(
/^(?:postgres(?:ql)?):\/\/(?:([^:@]*?)(?::([^@]*?))?@)?([^/:?]+)(?::(\d+))?(?:\/([^?]*))?(?:\?(.*))?$/
);
if (!match) return null;
const [, user, password, host, port, database, queryStr] = match;
const result: Partial<ConnectionConfig> = {};
if (host) result.host = decodeURIComponent(host);
if (port) result.port = parseInt(port, 10);
if (user) result.user = decodeURIComponent(user);
if (password !== undefined) result.password = decodeURIComponent(password);
if (database) result.database = decodeURIComponent(database);
if (queryStr) {
const params = new URLSearchParams(queryStr);
const ssl = params.get("sslmode");
if (ssl) result.ssl_mode = ssl;
}
return result;
} catch {
return null;
}
}
function buildDsn(config: ConnectionConfig): string {
const ssl = config.ssl_mode ?? "prefer";
const user = encodeURIComponent(config.user);
const pass = config.password ? `:${encodeURIComponent(config.password)}` : "";
const auth = config.user ? `${user}${pass}@` : "";
return `postgresql://${auth}${config.host}:${config.port}/${encodeURIComponent(config.database)}?sslmode=${ssl}`;
}
const CONNECTION_COLORS = [
{ name: "Red", value: "#ef4444" },
{ name: "Orange", value: "#f97316" },
@@ -53,14 +93,19 @@ const emptyConfig: ConnectionConfig = {
export function ConnectionDialog({ open, onOpenChange, connection }: Props) {
const [form, setForm] = useState<ConnectionConfig>(emptyConfig);
const [mode, setMode] = useState<InputMode>("fields");
const [dsn, setDsn] = useState("");
const [dsnError, setDsnError] = useState<string | null>(null);
const saveMutation = useSaveConnection();
const testMutation = useTestConnection();
useEffect(() => {
if (open) {
setForm(
connection ?? { ...emptyConfig, id: crypto.randomUUID() }
);
const config = connection ?? { ...emptyConfig, id: crypto.randomUUID() };
setForm(config);
setMode("fields");
setDsn(connection ? buildDsn(config) : "");
setDsnError(null);
}
}, [open, connection]);
@@ -116,76 +161,158 @@ export function ConnectionDialog({ open, onOpenChange, connection }: Props) {
placeholder="My Database"
/>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">
Host
Mode
</label>
<Input
className="col-span-3"
value={form.host}
onChange={(e) => update("host", e.target.value)}
/>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">
Port
</label>
<Input
className="col-span-3"
type="number"
value={form.port}
onChange={(e) => update("port", parseInt(e.target.value) || 5432)}
/>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">
User
</label>
<Input
className="col-span-3"
value={form.user}
onChange={(e) => update("user", e.target.value)}
/>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">
Password
</label>
<Input
className="col-span-3"
type="password"
value={form.password}
onChange={(e) => update("password", e.target.value)}
/>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">
Database
</label>
<Input
className="col-span-3"
value={form.database}
onChange={(e) => update("database", e.target.value)}
/>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">
SSL Mode
</label>
<Select
value={form.ssl_mode ?? "prefer"}
onValueChange={(v) => update("ssl_mode", v)}
>
<SelectTrigger className="col-span-3">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="disable">Disable</SelectItem>
<SelectItem value="prefer">Prefer</SelectItem>
<SelectItem value="require">Require</SelectItem>
</SelectContent>
</Select>
<div className="col-span-3 flex gap-1 rounded-md border p-0.5">
<button
type="button"
className={`flex-1 rounded-sm px-2 py-1 text-xs font-medium transition-colors ${
mode === "fields"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => {
if (mode === "dsn") {
const parsed = parseDsn(dsn);
if (parsed) {
setForm((f) => ({ ...f, ...parsed }));
setDsnError(null);
}
}
setMode("fields");
}}
>
Fields
</button>
<button
type="button"
className={`flex-1 rounded-sm px-2 py-1 text-xs font-medium transition-colors ${
mode === "dsn"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => {
setDsn(buildDsn(form));
setDsnError(null);
setMode("dsn");
}}
>
DSN
</button>
</div>
</div>
{mode === "dsn" ? (
<div className="grid grid-cols-4 items-start gap-3">
<label className="text-right text-sm text-muted-foreground pt-2">
DSN
</label>
<div className="col-span-3 space-y-1.5">
<Input
className="font-mono text-xs"
value={dsn}
onChange={(e) => {
const value = e.target.value;
setDsn(value);
if (value.trim()) {
const parsed = parseDsn(value);
if (parsed) {
setForm((f) => ({ ...f, ...parsed }));
setDsnError(null);
} else {
setDsnError("Invalid DSN format");
}
} else {
setDsnError(null);
}
}}
placeholder="postgresql://user:password@host:5432/database?sslmode=prefer"
/>
{dsnError && (
<p className="text-xs text-destructive">{dsnError}</p>
)}
<p className="text-[11px] text-muted-foreground">
postgresql://user:password@host:port/database?sslmode=prefer
</p>
</div>
</div>
) : (
<>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">
Host
</label>
<Input
className="col-span-3"
value={form.host}
onChange={(e) => update("host", e.target.value)}
/>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">
Port
</label>
<Input
className="col-span-3"
type="number"
value={form.port}
onChange={(e) => update("port", parseInt(e.target.value) || 5432)}
/>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">
User
</label>
<Input
className="col-span-3"
value={form.user}
onChange={(e) => update("user", e.target.value)}
/>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">
Password
</label>
<Input
className="col-span-3"
type="password"
value={form.password}
onChange={(e) => update("password", e.target.value)}
/>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">
Database
</label>
<Input
className="col-span-3"
value={form.database}
onChange={(e) => update("database", e.target.value)}
/>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">
SSL Mode
</label>
<Select
value={form.ssl_mode ?? "prefer"}
onValueChange={(v) => update("ssl_mode", v)}
>
<SelectTrigger className="col-span-3">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="disable">Disable</SelectItem>
<SelectItem value="prefer">Prefer</SelectItem>
<SelectItem value="require">Require</SelectItem>
</SelectContent>
</Select>
</div>
</>
)}
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">
Environment