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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user