feat: copy connection URL from connection card menu (#22)

Add 'Copy connection URL' (with password, fetched from the OS keychain on
demand) and 'Copy connection URL (no password)' to the connection card kebab
menu. Builds postgresql://, mysql://, sqlite://, redis:// strings with
percent-encoded credentials and the PG sslmode appended. Bump to v0.7.10.
This commit is contained in:
2026-08-13 14:33:54 +08:00
committed by GitHub
parent df3e3957a2
commit 6d1bbb0fa3
13 changed files with 254 additions and 36 deletions
@@ -200,4 +200,41 @@ describe("ConnectionCardMenu", () => {
await userEvent.keyboard("{Escape}");
expect(screen.queryByText("Manage")).not.toBeInTheDocument();
});
it("Copy connection URL fetches the password and copies the full URL", async () => {
vi.mocked(commands.getConnectionPassword).mockResolvedValue("s3cret");
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});
renderMenu();
await openMenu();
await userEvent.click(screen.getByText("Copy connection URL"));
expect(commands.getConnectionPassword).toHaveBeenCalledWith("c1");
expect(writeText).toHaveBeenCalledWith(
"postgresql://:s3cret@prod.example.com:5432",
);
expect(screen.queryByText("Manage")).not.toBeInTheDocument();
});
it("Copy connection URL (no password) does not fetch the password", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});
renderMenu();
await openMenu();
await userEvent.click(screen.getByText("Copy connection URL (no password)"));
expect(commands.getConnectionPassword).not.toHaveBeenCalled();
expect(writeText).toHaveBeenCalledWith(
"postgresql://prod.example.com:5432",
);
expect(screen.queryByText("Manage")).not.toBeInTheDocument();
});
});
@@ -5,6 +5,7 @@ import {
CircleCheck,
CircleX,
Copy,
EyeOff,
Loader2,
MoreVertical,
Pencil,
@@ -14,6 +15,7 @@ import {
import type { Connection } from "../../lib/types";
import { useConnectionStore } from "../../stores/connectionStore";
import { buildConfigFromConnection } from "./ConnectionCard";
import { buildConnectionUrl } from "../../lib/connectionString";
import { useConnectionStatus } from "./useConnectionStatus";
interface ConnectionCardMenuProps {
@@ -82,6 +84,19 @@ export function ConnectionCardMenu({
setManageOpen(false);
};
const copyUrl = async (withPassword: boolean) => {
let password: string | null = null;
if (withPassword) {
password = await useConnectionStore
.getState()
.getConnectionPassword(connection.id)
.catch(() => null);
}
const url = buildConnectionUrl(connection, password);
await navigator.clipboard.writeText(url).catch(() => {});
close();
};
const statusLabel =
state === "checking"
? "Testing…"
@@ -169,6 +184,28 @@ export function ConnectionCardMenu({
</span>
</button>
<button
type="button"
onClick={() => {
void copyUrl(true);
}}
className={menuItemClass}
>
<Copy size={14} className="text-text-muted" />
<span className="truncate">Copy connection URL</span>
</button>
<button
type="button"
onClick={() => {
void copyUrl(false);
}}
className={menuItemClass}
>
<EyeOff size={14} className="text-text-muted" />
<span className="truncate">Copy connection URL (no password)</span>
</button>
<button
type="button"
onClick={() => setManageOpen((m) => !m)}
+3 -3
View File
@@ -1,11 +1,11 @@
import { describe, it, expect } from "vitest";
import tauriConf from "../../src-tauri/tauri.conf.json";
describe("tauri bundle config (v0.7.9)", () => {
describe("tauri bundle config (v0.7.10)", () => {
it("declares bundled pg_tools resources", () => {
expect(tauriConf.bundle.resources).toContain("resources/pg_tools/*");
});
it("version is 0.7.9", () => {
expect(tauriConf.version).toBe("0.7.9");
it("version is 0.7.10", () => {
expect(tauriConf.version).toBe("0.7.10");
});
});
+92 -1
View File
@@ -1,5 +1,24 @@
import { describe, it, expect } from "vitest";
import { parseConnectionString, looksLikeConnectionString, detectProviderFromHost } from "./connectionString";
import { parseConnectionString, looksLikeConnectionString, detectProviderFromHost, buildConnectionUrl } from "./connectionString";
import type { Connection } from "./types";
function makeConn(overrides: Partial<Connection> = {}): Connection {
return {
id: "c1",
name: "Test",
db_type: "postgresql",
host: "localhost",
port: 5432,
username: "user",
folder_id: null,
keychain_ref: null,
tag_ids: [],
favorite: false,
created_at: "",
updated_at: "",
...overrides,
};
}
describe("parseConnectionString", () => {
it("parses a PostgreSQL URL", () => {
@@ -150,3 +169,75 @@ describe("detectProviderFromHost", () => {
expect(detectProviderFromHost("prod.example.com")).toBeNull();
});
});
describe("buildConnectionUrl", () => {
it("builds a PostgreSQL URL with password and sslmode", () => {
const url = buildConnectionUrl(
makeConn({ db_type: "postgresql", host: "prod.example.com", port: 5432, username: "alice", database: "app", ssl_mode: "require" }),
"s3cret",
);
expect(url).toBe("postgresql://alice:s3cret@prod.example.com:5432/app?sslmode=require");
});
it("omits password when null (no-password variant)", () => {
const url = buildConnectionUrl(
makeConn({ db_type: "postgresql", host: "prod.example.com", port: 5432, username: "alice", database: "app" }),
null,
);
expect(url).toBe("postgresql://alice@prod.example.com:5432/app");
});
it("omits sslmode when disable or unset", () => {
expect(
buildConnectionUrl(makeConn({ ssl_mode: "disable" }), null),
).toBe("postgresql://user@localhost:5432");
expect(
buildConnectionUrl(makeConn({ ssl_mode: undefined }), null),
).toBe("postgresql://user@localhost:5432");
});
it("percent-encodes special characters in user/password/database", () => {
const url = buildConnectionUrl(
makeConn({ username: "a b", database: "my db" }),
"p@ss w:rd",
);
expect(url).toBe("postgresql://a%20b:p%40ss%20w%3Ard@localhost:5432/my%20db");
});
it("builds a MySQL URL", () => {
const url = buildConnectionUrl(
makeConn({ db_type: "mysql", host: "127.0.0.1", port: 3306, username: "root", database: "app" }),
"pw",
);
expect(url).toBe("mysql://root:pw@127.0.0.1:3306/app");
});
it("builds a Redis URL with password but no username", () => {
const url = buildConnectionUrl(
makeConn({ db_type: "redis", host: "localhost", port: 6379, username: null, database: "0" }),
"pw",
);
expect(url).toBe("redis://:pw@localhost:6379/0");
});
it("builds a SQLite URL from the host file path", () => {
const url = buildConnectionUrl(
makeConn({ db_type: "sqlite", host: "/Users/me/data.db", port: null, username: null, database: null }),
null,
);
expect(url).toBe("sqlite:///Users/me/data.db");
});
it("round-trips through parseConnectionString", () => {
const conn = makeConn({ db_type: "postgresql", host: "localhost", port: 5432, username: "user", database: "mydb" });
const url = buildConnectionUrl(conn, "pass");
expect(parseConnectionString(url)).toEqual({
db_type: "postgresql",
host: "localhost",
port: 5432,
username: "user",
password: "pass",
database: "mydb",
});
});
});
+47 -1
View File
@@ -1,4 +1,4 @@
import type { DbType } from "./types";
import type { Connection, DbType } from "./types";
export interface ParsedConnectionString {
db_type: DbType;
@@ -89,3 +89,49 @@ export function detectProviderFromHost(host: string): "supabase" | "neon" | null
if (h.endsWith(".neon.tech")) return "neon";
return null;
}
/**
* Build a connection URL string from a saved connection, for copying into
* env vars / other tools. `password` is the decrypted keychain value (or
* `null` to omit it — e.g. the "no password" copy variant).
*
* Formats per DB type:
* - postgresql: `postgresql://user:pass@host:port/db?sslmode=...`
* - mysql: `mysql://user:pass@host:port/db`
* - sqlite: `sqlite:///abs/path` (host stores the file path)
* - redis: `redis://user:pass@host:port/db`
*
* Username/password/database are percent-encoded; the ssl_mode is appended as
* a query param for PostgreSQL when set to anything other than `disable`.
*/
export function buildConnectionUrl(conn: Connection, password: string | null): string {
const enc = (s: string) => encodeURIComponent(s);
const port = conn.port ? `:${conn.port}` : "";
const user = conn.username ? enc(conn.username) : "";
const auth = password
? user
? `${user}:${enc(password)}@`
: `:${enc(password)}@`
: user
? `${user}@`
: "";
const db = conn.database ? `/${enc(conn.database)}` : "";
switch (conn.db_type) {
case "postgresql": {
const ssl =
conn.ssl_mode && conn.ssl_mode !== "disable"
? `?sslmode=${encodeURIComponent(conn.ssl_mode)}`
: "";
return `postgresql://${auth}${conn.host}${port}${db}${ssl}`;
}
case "mysql":
return `mysql://${auth}${conn.host}${port}${db}`;
case "sqlite":
// host stores the file path; `sqlite:///abs/path` round-trips through
// parseConnectionString (pathname → host).
return `sqlite://${conn.host}`;
case "redis":
return `redis://${auth}${conn.host}${port}${db}`;
}
}
+7 -7
View File
@@ -4,7 +4,7 @@ import { describe, it, expect } from "vitest";
import agents from "../../AGENTS.md?raw";
import readme from "../../README.md?raw";
describe("v0.7.9 docs coverage", () => {
describe("v0.7.10 docs coverage", () => {
it("AGENTS.md marks inline cell editing complete", () => {
expect(agents).toContain("Inline cell editing");
expect(agents).toMatch(/Inline cell editing \| ✅/);
@@ -24,8 +24,8 @@ describe("v0.7.9 docs coverage", () => {
expect(agents).toMatch(/Connection status indicator on cards \| ✅/);
expect(agents).toMatch(/Move-to-folder bulk action \| ✅/);
});
it("README declares v0.7.9", () => {
expect(readme).toContain("0.7.9");
it("README declares v0.7.10", () => {
expect(readme).toContain("0.7.10");
});
it("AGENTS.md marks schema CRUD complete", () => {
expect(agents).toMatch(/Schema CRUD \| ✅/);
@@ -64,9 +64,9 @@ describe("v0.7.9 docs coverage", () => {
expect(agents).toMatch(/Cancel long-running queries \| ✅/);
expect(agents).toMatch(/Settings export\/import \| ✅/);
});
it("README links to v0.7.9 assets in both download tables", () => {
expect(readme).toContain("releases/download/v0.7.9/");
expect(readme).toContain("Gridline_0.7.9_aarch64.dmg");
expect(readme).toContain("Gridline-0.7.9-1.x86_64.rpm");
it("README links to v0.7.10 assets in both download tables", () => {
expect(readme).toContain("releases/download/v0.7.10/");
expect(readme).toContain("Gridline_0.7.10_aarch64.dmg");
expect(readme).toContain("Gridline-0.7.10-1.x86_64.rpm");
});
});
+2 -2
View File
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
import pkg from "../../package.json";
describe("version", () => {
it("declares v0.7.9 across the app shell", () => {
expect(pkg.version).toBe("0.7.9");
it("declares v0.7.10 across the app shell", () => {
expect(pkg.version).toBe("0.7.10");
});
});