v0.7.0: New Connection screen revamp + full MySQL DB viewer (#10)

* docs: correct competitor comparison for DB Pro, Beekeeper, TablePlus

Research-verified the 'Why Gridline vs the alternatives' claims against
vendor docs, pricing pages, GitHub, and release notes (May 2026):

- DB Pro is an Electron app (founder-confirmed), not native; add
  TablePlus column to the comparison table
- Fix wrong cells: DB Pro has query/dashboard folders + table tags and
  CSV/JSON export on the free tier; object-explorer depth corrected for
  DB Pro (tables/views/indexes/enums) and Beekeeper
  (tables/views/routines/triggers)
- Reframe differentiators: unlimited-everything framing dropped for
  Beekeeper (free tier is already unlimited on tabs/connections/queries);
  keep DB-to-DB sync as the genuinely unique feature
- Add a dated 'Competitor reality check' section to AGENTS.md so future
  edits don't re-assert inaccurate claims

* docs: add project roadmap, link it from README and AGENTS

New ROADMAP.md is the source of truth for planned work, reflecting the
in-flight v0.7.0 connection-screen-revamp spec (new-connection flow,
full MySQL DB viewer, capability gating, Supabase/Neon presets,
SQLite path mode, tag overflow scroll, styling sweep).

Next-up scope: PostgreSQL object management CRUD with companion
features (schema CRUD, global object search, copy-as-DDL, object
dependencies) and an admin follow-up (users/roles/grants,
VACUUM/ANALYZE/REINDEX). MySQL Objects view explicitly deferred.

Queue: Redis browsing, MariaDB/TimescaleDB, PlanetScale/Turso,
query workbench upgrades (multiple result sets, query cancel,
result streaming, visual query builder), schema/data tooling,
SQLite .dump, schema diff, more export formats.

Planned: BYOK AI, website & docs, rolling UI/UX polish (incl.
onboarding tour, settings import/export, SSH key management).

README roadmap section now links to ROADMAP.md; AGENTS.md Related
Documents + Implementation Status reference it and the v0.7.0 spec.

* docs: release notes reference prod as the production branch

The repo's production branch is prod (feature branches merge back to
prod), not main. Update the release-cut instructions in the README and
the trigger comment in release.yml.

* docs: add robust bug report issue template

Structured .github/ISSUE_TEMPLATE/bug_report.md covering environment
(OS, Gridline version, install type, DB type/version, hosted provider,
connection method incl. SSH/TLS/socket), steps to reproduce, expected vs
actual, screenshots, logs, impact, and workarounds — plus a duplicate
checklist and secrets-redaction note. Referenced from the README
Contributing section.

* docs: drop in-flight branch mention from roadmap; remove unused starter assets

- ROADMAP.md no longer references the in-flight feature branch/spec
  (removed at the end anyway when the branch PRs into prod)
- Remove unused Vite/Tauri starter SVGs from public/ (no favicon or
  asset references anywhere in the app)

* test: fix stale README comparison-table regex in docs-coverage (5-col table)

* feat: shared INPUT_ROUNDING constant + bump to v0.7.0 (Task 1.1)

* fix: map SQLite file path to host field + provider host detection (Task 1.2)

* test: bump version expectation to 0.7.0 (Task 1.1 follow-up)

* feat: db capability matrix for DB viewer gating (Task 1.3)

* feat: provider tab definitions, Supabase/Neon icons + setup guides (Task 1.4)

* feat(rust): MySQL SQL builders + identifier quoting (Task 2.1)

* chore(rust): sync Cargo.lock to gridline 0.7.0

* feat(rust): MySQL db_connect (SSL + SSH tunnel) + pool variant (Tasks 2.2-2.3)

* feat(rust): MySQL execute_query with wrapped pagination + raw fallback (Task 2.4)

* feat(rust): MySQL introspection + changes-queue editing + DDL (Task 2.5)

* fix(ui): show table toolbar immediately while tab is still loading first data

* feat: gate DB viewer sidebar nav by db capabilities (Task 3.1)

* feat: guard DB viewer views by capability + Redis unsupported state (Task 3.2)

* feat(ui): 2-column provider tab grid (Task 4.1)

* feat(ui): collapsible Supabase/Neon setup guide (Task 4.2)

* feat(ui): SQLite file-path input with Browse (Task 4.3)

* feat(ui): connection metadata row (label + tags/env/folder) (Task 4.4)

* feat(ui): rework GeneralTab (URI + OR + manual) + reduce Detailed form tabs (Task 4.5)

* feat(ui): NewConnectionScreen two-stage flow; remove SimpleConnectionForm (Task 5.1)

* feat(ui): scroll connection-card tag row past 3 tags (Task 5.2)

* style: sweep form controls from rounded-full to rounded-lg (Task 5.3)

* feat(ui): EditConnectionModal parity + managed-preset SSL hint (Task 5.4)

* fix(rust): decode MySQL VARBINARY metadata columns (information_schema/SHOW) as strings

* test: full suite green for v0.7.0 connection revamp (Task 5.5)

* feat(ui): schema dropdown + tables tree loading state while schema tree fetches

* docs: update AGENTS/README/ROADMAP for v0.7.0 (connection revamp, MySQL viewer, gating)
This commit is contained in:
2026-08-04 21:13:20 +08:00
committed by GitHub
parent 002d8345ae
commit 1195d2c3f9
67 changed files with 3150 additions and 649 deletions
+30 -4
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { parseConnectionString, looksLikeConnectionString } from "./connectionString";
import { parseConnectionString, looksLikeConnectionString, detectProviderFromHost } from "./connectionString";
describe("parseConnectionString", () => {
it("parses a PostgreSQL URL", () => {
@@ -38,15 +38,27 @@ describe("parseConnectionString", () => {
});
});
it("parses a SQLite file URL", () => {
it("parses a SQLite file URL — path maps to host (v0.7.0 fix)", () => {
const result = parseConnectionString("sqlite:///path/to/db.sqlite");
expect(result).toEqual({
db_type: "sqlite",
host: "localhost",
host: "/path/to/db.sqlite",
port: null,
username: null,
password: null,
database: "/path/to/db.sqlite",
database: null,
});
});
it("parses a SQLite file: URL — path maps to host", () => {
const result = parseConnectionString("file:///Users/me/data.db");
expect(result).toEqual({
db_type: "sqlite",
host: "/Users/me/data.db",
port: null,
username: null,
password: null,
database: null,
});
});
@@ -124,3 +136,17 @@ describe("looksLikeConnectionString", () => {
expect(looksLikeConnectionString("production database")).toBe(false);
});
});
describe("detectProviderFromHost", () => {
it("detects Supabase by host suffix", () => {
expect(detectProviderFromHost("db.abcdefghijklmnopqrst.supabase.co")).toBe("supabase");
expect(detectProviderFromHost("aws-us-east-1.pooler.supabase.com")).toBeNull();
});
it("detects NeonDB by host suffix", () => {
expect(detectProviderFromHost("ep-cool-darkness-a1b2c3d4-pooler.us-east-2.aws.neon.tech")).toBe("neon");
});
it("returns null for plain Postgres hosts", () => {
expect(detectProviderFromHost("localhost")).toBeNull();
expect(detectProviderFromHost("prod.example.com")).toBeNull();
});
});
+24 -4
View File
@@ -40,14 +40,24 @@ export function parseConnectionString(input: string): ParsedConnectionString | n
const db_type = DB_PROTOCOLS[url.protocol];
if (!db_type) return null;
const host = url.hostname || "localhost";
const port = url.port ? Number(url.port) : (DEFAULT_PORTS[db_type] ?? null);
const username = url.username || null;
const password = url.password || null;
const pathname = url.pathname;
const database = db_type === "sqlite"
? (pathname || null)
: (pathname.replace(/^\//, "") || null);
let host: string;
let database: string | null;
if (db_type === "sqlite") {
// SQLite: the file path lives in the URL pathname. Map it to `host`
// (the field the backend opens) and leave `database` null. `url.pathname`
// always starts with "/"; the fallback covers `sqlite://path` (no
// pathname) where the URL's hostname holds the relative path.
host = pathname || url.hostname || "";
database = null;
} else {
host = url.hostname || "localhost";
database = pathname.replace(/^\//, "") || null;
}
return {
db_type,
@@ -69,3 +79,13 @@ export function looksLikeConnectionString(input: string): boolean {
return false;
}
}
/** 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 {
const h = host.toLowerCase();
if (h.endsWith(".supabase.co")) return "supabase";
if (h.endsWith(".neon.tech")) return "neon";
return null;
}
+53
View File
@@ -0,0 +1,53 @@
import { describe, it, expect } from "vitest";
import { DB_CAPABILITIES, getCapabilities, type DbCapabilities } from "./dbCapabilities";
describe("dbCapabilities", () => {
it("gives PostgreSQL every capability", () => {
const c = DB_CAPABILITIES.postgresql;
expect(c).toEqual<DbCapabilities>({
explorer: true, queries: true, objects: true, visualizer: true,
tools: true, editing: true, import: true, ddl: true,
});
});
it("gives MySQL explorer/queries/editing/import/ddl but not objects/visualizer/tools", () => {
const c = DB_CAPABILITIES.mysql;
expect(c.explorer).toBe(true);
expect(c.queries).toBe(true);
expect(c.editing).toBe(true);
expect(c.import).toBe(true);
expect(c.ddl).toBe(true);
expect(c.objects).toBe(false);
expect(c.visualizer).toBe(false);
expect(c.tools).toBe(false);
});
it("gives SQLite explorer/queries/visualizer/editing/import/ddl but not objects/tools", () => {
const c = DB_CAPABILITIES.sqlite;
expect(c.explorer).toBe(true);
expect(c.queries).toBe(true);
expect(c.visualizer).toBe(true);
expect(c.editing).toBe(true);
expect(c.import).toBe(true);
expect(c.ddl).toBe(true);
expect(c.objects).toBe(false);
expect(c.tools).toBe(false);
});
it("gives Redis nothing (connection+test only)", () => {
expect(DB_CAPABILITIES.redis).toEqual<DbCapabilities>({
explorer: false, queries: false, objects: false, visualizer: false,
tools: false, editing: false, import: false, ddl: false,
});
});
it("getCapabilities returns all-false for an unknown type", () => {
const c = getCapabilities("postgres" as never);
expect(Object.values(c).every((v) => v === false)).toBe(true);
});
it("getCapabilities returns the matrix entry for known types", () => {
expect(getCapabilities("postgresql")).toBe(DB_CAPABILITIES.postgresql);
expect(getCapabilities("redis")).toBe(DB_CAPABILITIES.redis);
});
});
+37
View File
@@ -0,0 +1,37 @@
import type { DbType } from "./types";
export interface DbCapabilities {
/** Table browser + data grid. */
explorer: boolean;
/** SQL editor + history/saved. */
queries: boolean;
/** Functions/triggers/sequences/enums/extensions. */
objects: boolean;
/** ER schema diagram. */
visualizer: boolean;
/** Backup / restore / sync. */
tools: boolean;
/** Inline cell edits + changes queue. */
editing: boolean;
/** CSV/JSON import. */
import: boolean;
/** Copy table schema (DDL). */
ddl: boolean;
}
const ALL_FALSE: DbCapabilities = {
explorer: false, queries: false, objects: false, visualizer: false,
tools: false, editing: false, import: false, ddl: false,
};
export const DB_CAPABILITIES: Record<DbType, DbCapabilities> = {
postgresql: { ...ALL_FALSE, explorer: true, queries: true, objects: true, visualizer: true, tools: true, editing: true, import: true, ddl: true },
mysql: { ...ALL_FALSE, explorer: true, queries: true, editing: true, import: true, ddl: true },
sqlite: { ...ALL_FALSE, explorer: true, queries: true, visualizer: true, editing: true, import: true, ddl: true },
redis: { ...ALL_FALSE },
};
/** Safe accessor — unknown types get the all-false capability set. */
export function getCapabilities(dbType: string): DbCapabilities {
return (DB_CAPABILITIES as Record<string, DbCapabilities>)[dbType] ?? ALL_FALSE;
}
+40 -2
View File
@@ -1,4 +1,4 @@
import { siPostgresql, siMysql, siSqlite, siRedis } from "simple-icons";
import { siPostgresql, siMysql, siSqlite, siRedis, siSupabase, siNeon } from "simple-icons";
import type { DbType } from "./types";
// Brand colors from simple-icons
@@ -56,4 +56,42 @@ export const DB_ICONS: Record<DbType, string> = {
mysql: "🐬",
redis: "⚡",
sqlite: "🗄️",
};
};
// ── Managed-PostgreSQL provider icons (Supabase, NeonDB) ──────────
// These are NOT DbType values; connections persist as db_type="postgresql".
type ProviderIconId = "supabase" | "neon";
const PROVIDER_ICON_DATA: Record<ProviderIconId, { hex: string; path: string }> = {
supabase: { hex: `#${siSupabase.hex}`, path: siSupabase.path },
neon: { hex: `#${siNeon.hex}`, path: siNeon.path },
};
export const PROVIDER_LABELS: Record<ProviderIconId, string> = {
supabase: "Supabase",
neon: "NeonDB",
};
interface ProviderIconProps {
id: ProviderIconId;
size?: number;
className?: string;
}
export function ProviderIcon({ id, size = 20, className }: ProviderIconProps) {
const data = PROVIDER_ICON_DATA[id];
if (!data) return <span className="text-lg"></span>;
return (
<svg
role="img"
viewBox="0 0 24 24"
width={size}
height={size}
className={className}
fill={data.hex}
xmlns="http://www.w3.org/2000/svg"
>
<path d={data.path} />
</svg>
);
}
+6 -5
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.6.0 docs coverage", () => {
describe("v0.7.0 docs coverage", () => {
it("AGENTS.md marks inline cell editing complete", () => {
expect(agents).toContain("Inline cell editing");
expect(agents).toMatch(/Inline cell editing \| ✅/);
@@ -24,14 +24,15 @@ describe("v0.6.0 docs coverage", () => {
expect(agents).toMatch(/Connection status indicator on cards \| ✅/);
expect(agents).toMatch(/Move-to-folder bulk action \| ✅/);
});
it("README declares v0.6.0", () => {
expect(readme).toContain("0.6.0");
it("README declares v0.7.0", () => {
expect(readme).toContain("0.7.0");
});
it("README marks inline editing complete (not Upcoming)", () => {
// Key Features lists inline editing as a shipped feature
expect(readme).toMatch(/- \*\*Inline cell editing\*\* —/);
// comparison-table row for the stage→commit queue carries the ✅ marker
expect(readme).toMatch(/Changes queue \(stage → commit\)\s*\|[^|]*❌[^|]*\|[^|]*❌[^|]*\|\s*\*\*✅ Queue → Commit All\*\*/);
// comparison-table row for the stage→commit queue carries the Gridline ✅ marker
// (5-column table: Feature | DB Pro | Beekeeper | TablePlus | Gridline)
expect(readme).toMatch(/Changes queue \(stage → commit\)\s*\|[^|]*❌[^|]*\|[^|]*\|[^|]*\|\s*\*\*✅ Queue → Commit All\*\*/);
expect(readme).not.toMatch(/Inline cell editing.*Upcoming/);
});
});
+36
View File
@@ -0,0 +1,36 @@
import { describe, it, expect } from "vitest";
import { PROVIDER_TABS, getProviderById, SETUP_GUIDES } from "./providers";
describe("providers", () => {
it("defines exactly 6 provider tabs in order", () => {
expect(PROVIDER_TABS.map((p) => p.id)).toEqual([
"postgresql", "mysql", "sqlite", "redis", "supabase", "neon",
]);
});
it("marks only supabase and neon as managed presets mapping to postgresql", () => {
const pg = getProviderById("postgresql")!;
expect(pg.dbType).toBe("postgresql");
expect(pg.isManagedPreset).toBe(false);
const supa = getProviderById("supabase")!;
expect(supa.dbType).toBe("postgresql");
expect(supa.isManagedPreset).toBe(true);
const neon = getProviderById("neon")!;
expect(neon.dbType).toBe("postgresql");
expect(neon.isManagedPreset).toBe(true);
const redis = getProviderById("redis")!;
expect(redis.dbType).toBe("redis");
expect(redis.isManagedPreset).toBe(false);
});
it("getProviderById returns undefined for unknown id", () => {
expect(getProviderById("nope")).toBeUndefined();
});
it("ships setup guides for supabase and neon only, each with steps", () => {
expect(SETUP_GUIDES.supabase.steps.length).toBeGreaterThan(0);
expect(SETUP_GUIDES.neon.steps.length).toBeGreaterThan(0);
expect(SETUP_GUIDES.supabase.sslRequired).toBe(true);
expect(SETUP_GUIDES.neon.sslRequired).toBe(true);
});
});
+96
View File
@@ -0,0 +1,96 @@
import type { DbType } from "./types";
export type ProviderId = "postgresql" | "mysql" | "sqlite" | "redis" | "supabase" | "neon";
export interface ProviderTab {
id: ProviderId;
label: string;
/** The db_type persisted for a connection made via this tab. Supabase/Neon
* are managed PostgreSQL, so they persist as `postgresql`. */
dbType: DbType;
/** True for managed-PostgreSQL presets (Supabase/NeonDB). */
isManagedPreset: boolean;
}
export const PROVIDER_TABS: ProviderTab[] = [
{ id: "postgresql", label: "PostgreSQL", dbType: "postgresql", isManagedPreset: false },
{ id: "mysql", label: "MySQL", dbType: "mysql", isManagedPreset: false },
{ id: "sqlite", label: "SQLite", dbType: "sqlite", isManagedPreset: false },
{ id: "redis", label: "Redis", dbType: "redis", isManagedPreset: false },
{ id: "supabase", label: "Supabase", dbType: "postgresql", isManagedPreset: true },
{ id: "neon", label: "NeonDB", dbType: "postgresql", isManagedPreset: true },
];
export function getProviderById(id: string): ProviderTab | undefined {
return PROVIDER_TABS.find((p) => p.id === id);
}
export interface SetupGuideStep {
title: string;
detail: string;
}
export interface SetupGuide {
/** Short tagline shown on the provider card. */
blurb: string;
sslRequired: boolean;
steps: SetupGuideStep[];
}
/** 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> = {
supabase: {
blurb: "Managed PostgreSQL with a pooled connection string.",
sslRequired: true,
steps: [
{
title: "Open the Supabase Dashboard",
detail: "Open your project → click the Connect button at the top of the page.",
},
{
title: "Copy a Postgres connection string",
detail:
"Direct: postgresql://postgres:[YOUR-PASSWORD]@db.[project-ref].supabase.co:5432/postgres " +
"(needs IPv6, or the IPv4 add-on). For IPv4-only networks use the shared pooler session mode: " +
"postgres://postgres.[project-ref]:[YOUR-PASSWORD]@aws-[REGION].pooler.supabase.com:5432/postgres.",
},
{
title: "Paste it into Gridline's Connection URI",
detail: "It auto-detects as PostgreSQL; the Supabase tab highlights. Save stores the password in your OS keychain.",
},
{
title: "Enable SSL",
detail: "Supabase requires SSL — under SSH / SSL set the mode to Require (or Verify Full with the project root cert).",
},
],
},
neon: {
blurb: "Serverless Postgres with pooled or direct endpoints.",
sslRequired: true,
steps: [
{
title: "Open the Neon Console",
detail: "On your Project Dashboard, click Connect to open the 'Connect to your database' modal.",
},
{
title: "Pick branch, compute, database, role",
detail: "A connection string is built for you. The default is the pooled endpoint (host ends in -pooler…neon.tech).",
},
{
title: "Copy the connection string",
detail:
"postgresql://[role]:[password]@ep-[compute-id]-pooler.[region].aws.neon.tech/[dbname]?sslmode=require " +
"— port 5432 for both pooled and direct. If your client can't parse user:pass@host, fill Host/Port/User/Password manually.",
},
{
title: "Paste it into Gridline's Connection URI",
detail: "Auto-detects as PostgreSQL; the NeonDB tab highlights. Save stores the password in your OS keychain.",
},
{
title: "Enable SSL",
detail: "Neon requires SSL/TLS (strings ship with sslmode=require) — confirm SSH / SSL → mode Require.",
},
],
},
};
+8
View File
@@ -0,0 +1,8 @@
import { describe, it, expect } from "vitest";
import { INPUT_ROUNDING } from "./uiConstants";
describe("uiConstants", () => {
it("exposes a rounded (non-pill) radius class for inputs", () => {
expect(INPUT_ROUNDING).toBe("rounded-lg");
});
});
+4
View File
@@ -0,0 +1,4 @@
/** Shared Tailwind radius class for all form controls (inputs, selects, textareas).
* Pill-shaped (`rounded-full`) controls were retired in v0.7.0 in favor of a
* rounded rectangle. Import this constant so the radius is defined once. */
export const INPUT_ROUNDING = "rounded-lg";
+2 -2
View File
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
import pkg from "../../package.json";
describe("version", () => {
it("declares v0.6.0 across the app shell", () => {
expect(pkg.version).toBe("0.6.0");
it("declares v0.7.0 across the app shell", () => {
expect(pkg.version).toBe("0.7.0");
});
});