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
+1 -1
View File
@@ -248,7 +248,7 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
| Move-to-folder bulk action | ✅ | Selection toolbar → Move to Folder dialog (folder picker, move confirmed via dialog) | | Move-to-folder bulk action | ✅ | Selection toolbar → Move to Folder dialog (folder picker, move confirmed via dialog) |
| Favorites / Recent connections | ✅ | Star toggle in the connection card ⋮ menu (persisted `favorite` flag); Recent connections row (top 8 via `getRecentConnections`) | | Favorites / Recent connections | ✅ | Star toggle in the connection card ⋮ menu (persisted `favorite` flag); Recent connections row (top 8 via `getRecentConnections`) |
| Connection status indicator on cards | ✅ | Kebab menu → Test connection with inline idle/checking/online/offline result, on-demand via keychain + `testConnection`. **Reports real `server_version` + `latency_ms`** (PG/MySQL/SQLite queries + connect timing in the Rust backend); shows `Online · 16.4 · 42ms` or the error, re-check debounced 2s | | Connection status indicator on cards | ✅ | Kebab menu → Test connection with inline idle/checking/online/offline result, on-demand via keychain + `testConnection`. **Reports real `server_version` + `latency_ms`** (PG/MySQL/SQLite queries + connect timing in the Rust backend); shows `Online · 16.4 · 42ms` or the error, re-check debounced 2s |
| Connection card actions menu (⋮) | ✅ | Kebab dropdown: Favorite toggle, Test connection (inline status), Manage submenu (Edit… / Duplicate / Delete…) | | Connection card actions menu (⋮) | ✅ | Kebab dropdown: Favorite toggle, Test connection (inline status), **Copy connection URL** (with password) / **Copy connection URL (no password)** (builds a `postgresql://`/`mysql://`/`sqlite://`/`redis://` string from the saved connection, percent-encoded, sslmode appended for PG; password fetched from keychain on demand), Manage submenu (Edit… / Duplicate / Delete…) |
### Database Viewer ### Database Viewer
| Feature | Status | Details | | Feature | Status | Details |
+20 -18
View File
@@ -30,21 +30,21 @@
## Download ## Download
Grab the installer for your OS from the [latest release](https://github.com/AdrianBonpin/gridline/releases/latest) — the links below point at the current release (**v0.7.9**): Grab the installer for your OS from the [latest release](https://github.com/AdrianBonpin/gridline/releases/latest) — the links below point at the current release (**v0.7.10**):
| OS | Architecture | Download | | OS | Architecture | Download |
| :--------------------------- | :--------------------------- | :------------------------------------------------------------------------------------------------------------------------------- | | :--------------------------- | :--------------------------- | :------------------------------------------------------------------------------------------------------------------------------- |
| **macOS** | Apple Silicon (M1/M2/M3/M4…) | [Gridline_0.7.9_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.9/Gridline_0.7.9_aarch64.dmg) | | **macOS** | Apple Silicon (M1/M2/M3/M4…) | [Gridline_0.7.10_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.10/Gridline_0.7.10_aarch64.dmg) |
| **macOS** | Intel | [Gridline_0.7.9_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.9/Gridline_0.7.9_x64.dmg) | | **macOS** | Intel | [Gridline_0.7.10_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.10/Gridline_0.7.10_x64.dmg) |
| **Windows** | x64 | [Gridline_0.7.9_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.9/Gridline_0.7.9_x64-setup.exe) | | **Windows** | x64 | [Gridline_0.7.10_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.10/Gridline_0.7.10_x64-setup.exe) |
| **Debian / Ubuntu** | amd64 | [Gridline_0.7.9_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.9/Gridline_0.7.9_amd64.deb) | | **Debian / Ubuntu** | amd64 | [Gridline_0.7.10_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.10/Gridline_0.7.10_amd64.deb) |
| **Fedora / RHEL / openSUSE** | x86_64 | [Gridline-0.7.9-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.9/Gridline-0.7.9-1.x86_64.rpm) | | **Fedora / RHEL / openSUSE** | x86_64 | [Gridline-0.7.10-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.10/Gridline-0.7.10-1.x86_64.rpm) |
| **Other Linux** | amd64 | [Gridline_0.7.9_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.9/Gridline_0.7.9_amd64.AppImage) | | **Other Linux** | amd64 | [Gridline_0.7.10_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.10/Gridline_0.7.10_amd64.AppImage) |
> **macOS first launch:** macOS may say *"Gridline is damaged and can't be opened"* or *"the developer cannot be verified"* — this is expected; the app isn't Developer-ID signed/notarized yet. See the [macOS first-launch instructions](#installers-are-unsigned-for-now) (right-click → Open, or the one-time `xattr` fix). Not sure if your Mac is Intel or Apple Silicon? See [Which file should I download?](#which-file-should-i-download) below. > **macOS first launch:** macOS may say *"Gridline is damaged and can't be opened"* or *"the developer cannot be verified"* — this is expected; the app isn't Developer-ID signed/notarized yet. See the [macOS first-launch instructions](#installers-are-unsigned-for-now) (right-click → Open, or the one-time `xattr` fix). Not sure if your Mac is Intel or Apple Silicon? See [Which file should I download?](#which-file-should-i-download) below.
<!-- <!--
MAINTENANCE: These links are STATIC (versioned) — they point at the v0.7.9 MAINTENANCE: These links are STATIC (versioned) — they point at the v0.7.10
release assets, not at a moving "latest" target. On every new release, release assets, not at a moving "latest" target. On every new release,
update BOTH tables here (Download + Which file should I download?) to the update BOTH tables here (Download + Which file should I download?) to the
new version's asset names, which are tauri-action's default naming: new version's asset names, which are tauri-action's default naming:
@@ -89,6 +89,8 @@ Gridline is built for developers and small teams who manage multiple database en
<details> <details>
<summary>View the full changelog</summary> <summary>View the full changelog</summary>
- **v0.7.10** — **Copy connection URL**: from a connection card's ⋮ menu, copy the connection string for use in env vars / other tools — **Copy connection URL** (includes the password, fetched from the OS keychain on demand) and **Copy connection URL (no password)** (safe to share). Builds `postgresql://` / `mysql://` / `sqlite://` / `redis://` strings with percent-encoded credentials and the PG `sslmode` appended; version bump.
- **v0.7.9** — **macOS install/launch fixes**: the app's local store now opens under the OS app-data directory (a cwd-relative `gridline.db` made Finder-launched copies silently exit with a Rust panic before the UI started); macOS bundles are **ad-hoc signed at build time** (`signingIdentity "-"`), replacing the Xcode linker-only signature that macOS treated as unsigned ("damaged and can't be opened", silent Finder refusals) — users now get a standard one-time Gatekeeper prompt (see the [install notes](#installers-are-unsigned-for-now), incl. the `xattr` workaround); **OpenSSL is vendored** into the `ssh2` build so the release no longer depends on a Homebrew OpenSSL path; version bump. - **v0.7.9** — **macOS install/launch fixes**: the app's local store now opens under the OS app-data directory (a cwd-relative `gridline.db` made Finder-launched copies silently exit with a Rust panic before the UI started); macOS bundles are **ad-hoc signed at build time** (`signingIdentity "-"`), replacing the Xcode linker-only signature that macOS treated as unsigned ("damaged and can't be opened", silent Finder refusals) — users now get a standard one-time Gatekeeper prompt (see the [install notes](#installers-are-unsigned-for-now), incl. the `xattr` workaround); **OpenSSL is vendored** into the `ssh2` build so the release no longer depends on a Homebrew OpenSSL path; version bump.
- **v0.7.8** — **SQLite `.dump` backup/restore** plus **backup / restore / sync for MySQL & SQLite** (mysqldump-based, system-first); **Excel (.xlsx) export** in the grid toolbar alongside CSV/JSON/SQL/Markdown; **cancel long-running queries** per connection; **settings export/import** (JSON); **Windows/Linux title bar fix** (macOS-only overlay drag strip); **SQLite table editor** (Create/Edit Table with SQLite-aware type mapping); version bump. - **v0.7.8** — **SQLite `.dump` backup/restore** plus **backup / restore / sync for MySQL & SQLite** (mysqldump-based, system-first); **Excel (.xlsx) export** in the grid toolbar alongside CSV/JSON/SQL/Markdown; **cancel long-running queries** per connection; **settings export/import** (JSON); **Windows/Linux title bar fix** (macOS-only overlay drag strip); **SQLite table editor** (Create/Edit Table with SQLite-aware type mapping); version bump.
@@ -312,16 +314,16 @@ Code signing **will be added in the future** (Apple Developer Program + a Window
#### Which file should I download? #### Which file should I download?
Each release contains **one file per platform** — you only need the one that matches your computer. The links below point at the current release (**v0.7.9**): Each release contains **one file per platform** — you only need the one that matches your computer. The links below point at the current release (**v0.7.10**):
| Your system | Download this | Notes | | Your system | Download this | Notes |
| :------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------- | | :------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------- |
| macOS **Apple Silicon** (M1/M2/M3/M4…) | [Gridline_0.7.9_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.9/Gridline_0.7.9_aarch64.dmg) | `aarch64` = Apple's own chip | | macOS **Apple Silicon** (M1/M2/M3/M4…) | [Gridline_0.7.10_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.10/Gridline_0.7.10_aarch64.dmg) | `aarch64` = Apple's own chip |
| macOS **Intel** | [Gridline_0.7.9_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.9/Gridline_0.7.9_x64.dmg) | `x64` = Intel/AMD | | macOS **Intel** | [Gridline_0.7.10_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.10/Gridline_0.7.10_x64.dmg) | `x64` = Intel/AMD |
| **Windows** (most PCs) | [Gridline_0.7.9_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.9/Gridline_0.7.9_x64-setup.exe) | The `.msi` is an alternate installer (for enterprises/IT admins) | | **Windows** (most PCs) | [Gridline_0.7.10_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.10/Gridline_0.7.10_x64-setup.exe) | The `.msi` is an alternate installer (for enterprises/IT admins) |
| **Debian / Ubuntu** | [Gridline_0.7.9_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.9/Gridline_0.7.9_amd64.deb) | Install: `sudo apt install ./Gridline_0.7.9_amd64.deb` | | **Debian / Ubuntu** | [Gridline_0.7.10_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.10/Gridline_0.7.10_amd64.deb) | Install: `sudo apt install ./Gridline_0.7.10_amd64.deb` |
| **Fedora / RHEL / openSUSE** | [Gridline-0.7.9-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.9/Gridline-0.7.9-1.x86_64.rpm) | Install: `sudo dnf install Gridline-0.7.9-1.x86_64.rpm` | | **Fedora / RHEL / openSUSE** | [Gridline-0.7.10-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.10/Gridline-0.7.10-1.x86_64.rpm) | Install: `sudo dnf install Gridline-0.7.10-1.x86_64.rpm` |
| **Any other Linux** | [Gridline_0.7.9_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.9/Gridline_0.7.9_amd64.AppImage) | Works on every distro: `chmod +x` the file, then double-click it | | **Any other Linux** | [Gridline_0.7.10_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.10/Gridline_0.7.10_amd64.AppImage) | Works on every distro: `chmod +x` the file, then double-click it |
**Not sure if your Mac is Intel or Apple Silicon?** Click the **Apple menu** → **About This Mac**. If it shows "Apple M1/M2/M3/M4…" download the `aarch64` file; if it shows an Intel chip, download `x64`. Downloading the wrong one won't run. **Not sure if your Mac is Intel or Apple Silicon?** Click the **Apple menu** → **About This Mac**. If it shows "Apple M1/M2/M3/M4…" download the `aarch64` file; if it shows an Intel chip, download `x64`. Downloading the wrong one won't run.
@@ -331,8 +333,8 @@ Cutting a release is one command — CI builds everything. **Releases are cut fr
```bash ```bash
git checkout prod && git pull git checkout prod && git pull
git tag v0.7.9 git tag v0.7.10
git push origin v0.7.9 git push origin v0.7.10
``` ```
GitHub Actions (`.github/workflows/release.yml`) builds installers for **Apple Silicon, Intel Macs, Windows, and Linux**, then opens a **draft release** on the [Releases](https://github.com/adrianbonpin/gridline/releases) page — review it and hit **Publish release**. GitHub Actions (`.github/workflows/release.yml`) builds installers for **Apple Silicon, Intel Macs, Windows, and Linux**, then opens a **draft release** on the [Releases](https://github.com/adrianbonpin/gridline/releases) page — review it and hit **Publish release**.
@@ -416,7 +418,7 @@ gridline/
## Roadmap ## Roadmap
The full plan — next-up (v0.7.9), queue, and shipped history — lives in **[ROADMAP.md](./ROADMAP.md)**. The full plan — next-up (v0.7.10), queue, and shipped history — lives in **[ROADMAP.md](./ROADMAP.md)**.
Highlights of what's next: Highlights of what's next:
+5
View File
@@ -6,6 +6,11 @@ This file is the **source of truth** for what Gridline is building. [AGENTS.md](
--- ---
## ✅ Shipped (0.7.10)
- **Copy connection URL** — from a connection card's ⋮ menu, copy the connection string for use in env vars / other tools: **Copy connection URL** (includes the password, fetched from the OS keychain on demand) and **Copy connection URL (no password)** (safe to share). Builds `postgresql://` / `mysql://` / `sqlite://` / `redis://` strings with percent-encoded credentials and the PG `sslmode` appended.
- **Version bump** 0.7.9 → **0.7.10**.
## ✅ Shipped (0.7.9) ## ✅ Shipped (0.7.9)
- **macOS Finder launch fix** — the local store opens under the OS app-data directory instead of a cwd-relative `gridline.db` (Finder/LaunchServices launches run with cwd `/`, so the old path made the app silently exit with a Rust panic before the UI started). - **macOS Finder launch fix** — the local store opens under the OS app-data directory instead of a cwd-relative `gridline.db` (Finder/LaunchServices launches run with cwd `/`, so the old path made the app silently exit with a Rust panic before the UI started).
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "gridline", "name": "gridline",
"private": true, "private": true,
"version": "0.7.9", "version": "0.7.10",
"description": "An open-source, high-performance database GUI client for PostgreSQL and beyond", "description": "An open-source, high-performance database GUI client for PostgreSQL and beyond",
"type": "module", "type": "module",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "gridline" name = "gridline"
version = "0.7.9" version = "0.7.10"
description = "An open-source, high-performance database GUI client for PostgreSQL and beyond" description = "An open-source, high-performance database GUI client for PostgreSQL and beyond"
authors = ["you"] authors = ["you"]
edition = "2021" edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Gridline", "productName": "Gridline",
"version": "0.7.9", "version": "0.7.10",
"identifier": "com.adrianbonpin.gridline", "identifier": "com.adrianbonpin.gridline",
"build": { "build": {
"beforeDevCommand": "bun run dev", "beforeDevCommand": "bun run dev",
@@ -200,4 +200,41 @@ describe("ConnectionCardMenu", () => {
await userEvent.keyboard("{Escape}"); await userEvent.keyboard("{Escape}");
expect(screen.queryByText("Manage")).not.toBeInTheDocument(); 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, CircleCheck,
CircleX, CircleX,
Copy, Copy,
EyeOff,
Loader2, Loader2,
MoreVertical, MoreVertical,
Pencil, Pencil,
@@ -14,6 +15,7 @@ import {
import type { Connection } from "../../lib/types"; import type { Connection } from "../../lib/types";
import { useConnectionStore } from "../../stores/connectionStore"; import { useConnectionStore } from "../../stores/connectionStore";
import { buildConfigFromConnection } from "./ConnectionCard"; import { buildConfigFromConnection } from "./ConnectionCard";
import { buildConnectionUrl } from "../../lib/connectionString";
import { useConnectionStatus } from "./useConnectionStatus"; import { useConnectionStatus } from "./useConnectionStatus";
interface ConnectionCardMenuProps { interface ConnectionCardMenuProps {
@@ -82,6 +84,19 @@ export function ConnectionCardMenu({
setManageOpen(false); 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 = const statusLabel =
state === "checking" state === "checking"
? "Testing…" ? "Testing…"
@@ -169,6 +184,28 @@ export function ConnectionCardMenu({
</span> </span>
</button> </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 <button
type="button" type="button"
onClick={() => setManageOpen((m) => !m)} onClick={() => setManageOpen((m) => !m)}
+3 -3
View File
@@ -1,11 +1,11 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import tauriConf from "../../src-tauri/tauri.conf.json"; 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", () => { it("declares bundled pg_tools resources", () => {
expect(tauriConf.bundle.resources).toContain("resources/pg_tools/*"); expect(tauriConf.bundle.resources).toContain("resources/pg_tools/*");
}); });
it("version is 0.7.9", () => { it("version is 0.7.10", () => {
expect(tauriConf.version).toBe("0.7.9"); expect(tauriConf.version).toBe("0.7.10");
}); });
}); });
+92 -1
View File
@@ -1,5 +1,24 @@
import { describe, it, expect } from "vitest"; 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", () => { describe("parseConnectionString", () => {
it("parses a PostgreSQL URL", () => { it("parses a PostgreSQL URL", () => {
@@ -150,3 +169,75 @@ describe("detectProviderFromHost", () => {
expect(detectProviderFromHost("prod.example.com")).toBeNull(); 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 { export interface ParsedConnectionString {
db_type: DbType; db_type: DbType;
@@ -89,3 +89,49 @@ export function detectProviderFromHost(host: string): "supabase" | "neon" | null
if (h.endsWith(".neon.tech")) return "neon"; if (h.endsWith(".neon.tech")) return "neon";
return null; 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 agents from "../../AGENTS.md?raw";
import readme from "../../README.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", () => { it("AGENTS.md marks inline cell editing complete", () => {
expect(agents).toContain("Inline cell editing"); expect(agents).toContain("Inline cell editing");
expect(agents).toMatch(/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(/Connection status indicator on cards \| ✅/);
expect(agents).toMatch(/Move-to-folder bulk action \| ✅/); expect(agents).toMatch(/Move-to-folder bulk action \| ✅/);
}); });
it("README declares v0.7.9", () => { it("README declares v0.7.10", () => {
expect(readme).toContain("0.7.9"); expect(readme).toContain("0.7.10");
}); });
it("AGENTS.md marks schema CRUD complete", () => { it("AGENTS.md marks schema CRUD complete", () => {
expect(agents).toMatch(/Schema CRUD \| ✅/); 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(/Cancel long-running queries \| ✅/);
expect(agents).toMatch(/Settings export\/import \| ✅/); expect(agents).toMatch(/Settings export\/import \| ✅/);
}); });
it("README links to v0.7.9 assets in both download tables", () => { it("README links to v0.7.10 assets in both download tables", () => {
expect(readme).toContain("releases/download/v0.7.9/"); expect(readme).toContain("releases/download/v0.7.10/");
expect(readme).toContain("Gridline_0.7.9_aarch64.dmg"); expect(readme).toContain("Gridline_0.7.10_aarch64.dmg");
expect(readme).toContain("Gridline-0.7.9-1.x86_64.rpm"); 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"; import pkg from "../../package.json";
describe("version", () => { describe("version", () => {
it("declares v0.7.9 across the app shell", () => { it("declares v0.7.10 across the app shell", () => {
expect(pkg.version).toBe("0.7.9"); expect(pkg.version).toBe("0.7.10");
}); });
}); });