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
+81
View File
@@ -0,0 +1,81 @@
---
name: Bug report
about: Report a bug or unexpected behavior in Gridline so we can fix it
title: "[Bug]: "
labels: ["bug"]
assignees: ''
---
<!--
Thank you for reporting a bug! A complete report is the fastest way to get it fixed.
Please fill in every section you can — the environment table alone often closes issues.
-->
## Before you submit
- [ ] I searched [existing issues](https://github.com/AdrianBonpin/gridline/issues?q=is%3Aissue) and this isn't a duplicate
- [ ] I'm on the latest release (check Help → About, or the [Releases](https://github.com/AdrianBonpin/gridline/releases) page)
- [ ] I will **not** paste passwords, connection strings containing credentials, or other secrets — if needed, redact them as `<REDACTED>`
## Summary
<!-- One or two sentences: what happened, and why it's a problem. -->
## Environment
| Field | Value |
| :--- | :--- |
| OS + version | <!-- e.g. macOS 14.5, Windows 11 24H2, Ubuntu 24.04 --> |
| Gridline version | <!-- e.g. 0.7.0 (check Help → About) --> |
| Install type | <!-- e.g. .dmg / .exe / .deb / .AppImage / built from source (`bun run tauri dev`) --> |
| Database type | <!-- PostgreSQL / MySQL / SQLite / Redis --> |
| Database version | <!-- e.g. PostgreSQL 16.4, MySQL 8.0.36, SQLite 3.45 --> |
| Where the DB runs | <!-- local / remote / hosted provider (Supabase, Neon, PlanetScale, Turso, …) --> |
| Connection method | <!-- direct TCP / SSH tunnel / TLS mode (disable / require / verify-ca / verify-full) / Unix socket / SQLite file path --> |
| Gridline architecture | <!-- e.g. Apple Silicon, Intel, x86_64, ARM64 --> |
## What did you expect to happen?
<!-- e.g. "Opening the table shows all 100k rows in the grid." -->
## What actually happened?
<!-- e.g. "The grid shows a 'failed to fetch table data' error after ~10 seconds." Include the exact error text if visible. -->
## Steps to reproduce
<!-- Numbered, minimal steps. Include the exact SQL/operations where relevant. -->
1. Open Gridline and connect to …
2. Click …
3.
## Screenshots / recordings
<!-- Drag & drop images or a short screen recording (LICEcap, QuickTime, etc.). Redact any sensitive data first. -->
## Logs & error messages
<!--
Paste the error text, console output, or a crash report here.
On macOS: Console.app → Gridline. On Windows: Event Viewer → Application.
Remove anything that looks like a host/user/password/token before pasting.
-->
```
(paste logs here)
```
## Impact
- [ ] Blocking — can't use a core feature at all
- [ ] Major — core feature works around it only with a workaround
- [ ] Minor — cosmetic or edge case
## Workaround
<!-- Anything that gets you unstuck today (e.g. "works when I connect without SSH", "restarting the app fixes it"). Even a partial workaround helps others. -->
## Additional context
<!-- Anything else: does it reproduce with a fresh connection or only one specific database? Did it work in an earlier version? Frequency (always / sometimes)? -->
+2 -2
View File
@@ -3,8 +3,8 @@ name: Release
# Builds Gridline installers for macOS (Apple Silicon + Intel), Windows, and
# Linux, then uploads them to a draft GitHub Release.
#
# Trigger: push a version tag from the `main` branch (production), e.g.
# git checkout main && git pull
# Trigger: push a version tag from the `prod` branch (production), e.g.
# git checkout prod && git pull
# git tag v0.5.0 && git push origin v0.5.0
#
# SIGNING STATUS: builds are UNSIGNED for now (no code-signing certs yet —
+20 -7
View File
@@ -8,10 +8,15 @@ Guidance for AI coding agents working on **Gridline**.
Gridline is an **open-source, cross-platform database GUI client** for PostgreSQL (with MySQL, SQLite, and Redis to follow). It is built as a **Tauri 2.0 desktop app** — a lightweight native shell (~40MB baseline) around a React web frontend, with a Rust backend handling all database operations, CLI tool orchestration, and local persistence.
**Core differentiators from commercial alternatives (DB Pro, TablePlus, etc.):**
- No paywalls — unlimited tabs, connections, and saved queries by default
- First-class PostgreSQL administration: `pg_dump`, `pg_restore`, DB-to-DB sync
- Full object explorer: Functions, Triggers, Sequences, Enums, Extensions — not just tables
**Core differentiators from commercial alternatives (DB Pro, TablePlus, Beekeeper Studio):**
- **Everything free, nothing paywalled** — where DB Pro caps free users at 2 connections / 3 tabs / 5 saved queries, TablePlus caps at 2 open tabs + 2 windows, and Beekeeper reserves backup/restore, file import, multi-table export, ERD, and several DB connectors (Oracle, MongoDB, ClickHouse…) for paid tiers, Gridline ships the full feature set with no limits on tabs, connections, or saved queries
- **DB-to-DB sync** — pipe-based `pg_dump` `pg_restore` between two live connections; none of the alternatives (DB Pro, TablePlus, Beekeeper) offer direct DB-to-DB sync — they only back up to / restore from files
- **Deeper PostgreSQL object explorer** — full detail views for Functions, Triggers, Sequences, Enums, and Extensions; Beekeeper and TablePlus show tables/views/routines/triggers but no sequences, enums, or extensions (Beekeeper can't even display routine definitions — issue #329 open since 2020), while DB Pro's tree stops at tables, views, indexes, and enums
**Competitor reality check (verified 2026-05, from vendor docs/pricing/repos — keep this accurate):**
- **DB Pro** (dbpro.app): **Electron app** (founder-confirmed on HN; launched Nov 2025) — not native despite "native macOS, Windows, Linux apps" marketing copy. Free plan = 2 connections / 5 saved queries / 3 open tabs / 2 dashboards / 2 table tags; data imports + SSH tunneling are paid-only per the pricing table/FAQ, while CSV/JSON export **does work on the free tier** (paid plans advertise "unlimited exports"; FAQ inconsistently claims "unlimited local connections"). Has query folders, dashboard folders, and table tags (roadmap 100%). No backup/restore (no pg_dump/pg_restore anywhere) and no DB-to-DB sync — the "Deeper Database Management" roadmap (indexes, users, constraints, VACUUM/ANALYZE) is still 0%. Schema tree: tables, views, indexes, relationships, and enums (since v1.6.0); MSSQL also lists stored procedures — no functions, triggers, sequences, or extensions on PG. Timeline: v1.0 Nov 2025 → v1.4 MSSQL/SSH/Keychain/Neon (Jan 2026) → v1.6 Redis/enums (Feb 2026) → self-hosted Studio (Mar 2026). Marketing overclaims ("native", Neon listed before it shipped) and known bugs (strict TLS verification blocks some Supabase pooler connections).
- **Beekeeper Studio**: free Community edition = unlimited connections, no tab limits, saved queries, local folders (5.7+), staged Apply/Discard edits, basic query-result export. Paid-only: pg_dump/pg_restore backup/restore, file import, multi-table export, ERD, AI shell, JSON sidebar, cloud workspaces, and premium DB connectors (Oracle, MongoDB, ClickHouse, DuckDB…). Sidebar shows tables/views/matviews/routines/triggers — no sequences, enums, or extensions.
- **TablePlus**: free = 2 open tabs / 2 windows / 2 advanced filters, but every other feature is included (incl. pg_dump/mysqldump backup GUI). No DB-to-DB sync, no ERD, no folder hierarchy. Sidebar: tables, views, functions, procedures.
**Target audience:** Developers managing multiple database environments across projects (Personal, Work, Client). The workspace/folder hierarchy is a first-class concept.
@@ -182,15 +187,18 @@ cargo test # Rust tests
✅ = Complete &nbsp; 🟡 = Partial/Stub &nbsp; ❌ = Not Started
Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of truth for what's next); this table reflects the current codebase and may lag planned work. See also the [architectural spec for the in-flight v0.7.0 work](./docs/superpowers/specs/2026-08-04-architectural-spec.md).
### Connection Management
| Feature | Status | Details |
| :--- | :---: | :--- |
| Connections CRUD (PostgreSQL, MySQL, SQLite, Redis) | ✅ | Full create/read/update/delete with form validation |
| New Connection screen (revamped) | ✅ | Two-stage entry → configured flow: Connection URI + 6-card provider grid (PostgreSQL / MySQL / SQLite / Redis / Supabase / NeonDB) with an OR divider → expands into label + tags/env/folder + General|SSH·SSL tabs. Supabase & NeonDB are managed-PostgreSQL presets (persist as `postgresql`) with in-app setup guides + SSL hints; SQLite swaps the URI field for a file-path + Browse input (v0.7.0) |
| Connection testing (all DB types) | ✅ | PostgreSQL, MySQL, SQLite, Redis all testable |
| DB Viewer: PostgreSQL browse + query | ✅ | Schemas, tables, paginated data, FK preview, JSON viewer |
| DB Viewer: SQLite browse + query | ✅ | Full support via rusqlite |
| DB Viewer: MySQL browse | | Test connection works; browsing not wired |
| DB Viewer: Redis browse | ❌ | Test connection works; browsing not wired |
| DB Viewer: MySQL browse + query + edit | | Full viewer: connect (SSL + SSH tunnel), databases/tables/columns/FKs, query + pagination, inline cell editing + changes queue, DDL copy (`SHOW CREATE TABLE`), CSV/JSON import — added in v0.7.0. PK-only editing (no ctid equivalent); VARBINARY `information_schema` columns decoded correctly |
| DB Viewer: Redis browse | ❌ | Connection + test only; browsing gated off with a clean "not supported" state (v0.7.0) |
| Password storage in OS keychain | ✅ | macOS Keychain, Linux Secret Service, Windows Credential Manager |
| SSH tunnel config UI | ✅ | Host, port, user, auth method, key path, passphrase fields |
| SSH tunnel runtime | ✅ | Real ssh2 tunnel (password + key auth), binds 127.0.0.1 only, secrets in OS keychain (`ssh_password:<id>` / `ssh_passphrase:<id>`), closed on pool eviction / app exit; TLS downgraded to `require` through the tunnel |
@@ -203,6 +211,7 @@ cargo test # Rust tests
| Connection cards grid (by folder) | ✅ | Grouped display, single-click to open DB viewer |
| Folders CRUD | ✅ | Nested folders, reparent on delete, breadcrumb nav |
| Tags CRUD | ✅ | Colors, drag reorder, filter connections by tag |
| Tag overflow scroll on cards | ✅ | Connection cards show up to 3 tags, then the row scrolls horizontally (v0.7.0) |
| Tag filter dropdown | ✅ | ActionRow Tags button → dropdown with checkboxes, active-count badge, Manage tags → Settings. **OR semantics** — a connection shows if it has ANY selected tag (not all) |
| Folder tag matching | ✅ | When any filter is active, folder cards show only if the folder matches a selected tag OR contains matching connections (directly or in subfolders) |
| DB type filter (Postgres/MySQL/SQLite/Redis) | ✅ | Dropdown with checkboxes + Clear all; folder cards hidden when their contents don't match the DB type |
@@ -222,7 +231,9 @@ cargo test # Rust tests
| Feature | Status | Details |
| :--- | :---: | :--- |
| Multi-tab table browser | ✅ | Open tables in tabs, close with Cmd/Ctrl+W |
| Schema/database selector | ✅ | Ghost-style dropdowns, single-row layout |
| DB viewer capability gating | ✅ | `dbCapabilities.ts` matrix per `db_type` (PG full; SQLite explorer/queries/visualizer/editing/import; MySQL explorer/queries/editing/import; Redis none); unsupported views show a clean "not supported" state. Redis browsing gated off (v0.7.0) |
| Schema/database selector | ✅ | Ghost-style dropdowns, single-row layout; schema dropdown + tables tree show a loading state while the schema tree is still fetching, instead of an empty "no tables" state (v0.7.0) |
| Table toolbar during load | ✅ | Toolbar renders immediately when a tab opens while data is still fetching, so the loading state is visible (v0.7.0) |
| Refresh database (spin + success/error feedback) | ✅ | Re-fetches databases, schemas, and tables |
| Search tables filter | ✅ | Animated input, real-time filter by name, auto-hide on blur |
| Column metadata (PK, FK, type, nullable, default) | ✅ | Expand table row to see columns with icons. ENUM/custom types resolved via udt_name, cast ::text for data retrieval. |
@@ -330,6 +341,8 @@ cargo test # Rust tests
## Related Documents
- [Project Roadmap](./ROADMAP.md) — source of truth for planned work (in-development, next-up, queue, shipped)
- [Architectural Spec: v0.7.0 connection-screen revamp](./docs/superpowers/specs/2026-08-04-architectural-spec.md) — current in-flight work
- [Tauri 2.0 Documentation](https://tauri.app/develop/)
- [sqlx Documentation](https://docs.rs/sqlx)
- [Monaco Editor API](https://microsoft.github.io/monaco-editor/api/)
+45 -49
View File
@@ -5,7 +5,7 @@
<h1>Gridline</h1>
<p>
<i>A lightweight, open-source database GUI for PostgreSQL and SQLite.</i><br />
<i>A lightweight, open-source database GUI for PostgreSQL, MySQL, SQLite, and Redis.</i><br />
Unlimited connections, tabs, and saved queries — with first-class <code>pg_dump</code>, <code>pg_restore</code>, and DB-to-DB sync.
</p>
@@ -35,6 +35,7 @@ Gridline is a modern, open-source database GUI client built with [Tauri 2.0](htt
- **No caps** on connections, tabs, or saved queries.
- **Deep PostgreSQL tooling** — visual `pg_dump`, `pg_restore`, and DB-to-DB sync.
- **Full MySQL + SQLite browsing** — connect, browse, query, and edit MySQL and SQLite the same way you do PostgreSQL.
- **Full object explorer** — not just tables, but functions, triggers, sequences, enums, extensions, materialized views, and procedures.
- **Interactive ER diagram** — explore relationships visually with crow's-foot cardinality notation.
- **Production-safe editing** — stage INSERT/UPDATE/DELETE changes, review the generated SQL, then commit all at once.
@@ -55,6 +56,7 @@ Gridline is built for developers and small teams who manage multiple database en
## Recent Changes
- **2026-08-04:** v0.7.0 — revamped the New Connection screen into a two-stage flow with a 6-provider grid (PostgreSQL, MySQL, SQLite, Redis, Supabase, NeonDB; managed presets ship with setup guides + SSL hints) and added full MySQL DB viewer support (connect, browse, query, inline cell editing + changes queue, DDL copy).
- **2026-08-04:** Revamped the built-in SQLite demo database with realistic e-commerce data (20 users, 24 products, 50 orders, 100 page views, 500 audit rows) and renamed it to **Gridline Demo (SQLite)**.
- **2026-07-XX:** Added inline cell editing with a stage-first changes queue, row-detail drawer, keyboard navigation, and cell-level copy.
- **2026-07-XX:** Added visual filter builder with drag-and-drop column palette and type-aware operators.
@@ -72,6 +74,7 @@ Gridline is built for developers and small teams who manage multiple database en
### Connections & Workspace
- **URI auto-fill** — paste `postgres://`, `mysql://`, `sqlite://`, or `redis://` strings and have all fields populate automatically.
- **Provider grid** — pick PostgreSQL, MySQL, SQLite, Redis, Supabase, or NeonDB; managed presets surface in-app setup guides and an SSL hint.
- **Workspace tree** — multi-level folders, color-coded tags, favorites, and recent connections.
- **OS keychain storage** — passwords and SSH secrets live in macOS Keychain / Linux Secret Service / Windows Credential Manager, never in plaintext.
- **SSH tunneling** — real `ssh2` tunnels with password or key authentication.
@@ -160,22 +163,26 @@ Gridline is built for developers and small teams who manage multiple database en
## Why Gridline vs the alternatives?
| Capability | DB Pro (Free) | Beekeeper (Free) | Gridline |
| :----------------------------- | :------------: | :----------------: | :---------------------------------------------------: |
| Open tabs | 3 | Unlimited | **Unlimited** |
| Saved connections | 2 | Unlimited | **Unlimited** |
| Saved queries | 5 | Unlimited | **Unlimited** |
| Data export (CSV, JSON, SQL) | ❌ paid only | Basic only | **JSON, CSV, SQL, Markdown** |
| `pg_dump` / `pg_restore` GUI | ❌ | ❌ paid only | **First-class UI** |
| DB-to-DB sync | ❌ | | **Built-in pipe sync** |
| Object explorer depth | Tables, views | Tables, views | **Functions, Triggers, Enums, Sequences, Extensions** |
| ER diagram / schema visualizer | ❌ | ❌ paid only | **✅ React Flow + dagre** |
| SSH tunneling | 🔒 likely paid | ✅ | **✅ Password + key auth, keychain** |
| OS credential vault | | | **Keychain / Secret Service / Credential Manager** |
| Workspace / folder hierarchy | ❌ | | **Multi-level tree + tags** |
| Changes queue (stage → commit) | ❌ | | **✅ Queue → Commit All** |
| Desktop shell size | Native | Electron (~250 MB) | **Tauri 2.0 (~40 MB)** |
| Open source | ❌ | ✅ GPLv3 | **Apache 2.0** |
Capabilities below are fact-checked against each vendor's official docs and pricing (May 2026).
| Capability | DB Pro (Free) | Beekeeper (Free) | TablePlus (Free) | Gridline |
| :----------------------------- | :--------------------------: | :-----------------------------: | :-------------------------: | :--------------------------------------------------------: |
| Open tabs | 3 | Unlimited | 2 | **Unlimited** |
| Saved connections | 2 | Unlimited | Unlimited | **Unlimited** |
| Saved queries | 5 | Unlimited | Unlimited | **Unlimited** |
| Data export (CSV, JSON, SQL) | ✅ (unlimited = paid) | Basic only | | **JSON, CSV, SQL, Markdown** |
| `pg_dump` / `pg_restore` GUI | ❌ | ❌ paid only | ✅ | **First-class UI** |
| DB-to-DB sync | ❌ | ❌ | ❌ | **Built-in pipe sync** |
| Object explorer depth | Tables, views, indexes, enums | Tables, views, routines, triggers | Tables, views, functions, procedures | **Functions, Triggers, Sequences, Enums, Extensions + full detail** |
| ER diagram / schema visualizer | ✅ view-only | ❌ paid only | | **✅ React Flow + dagre** |
| SSH tunneling | paid only | | ✅ | **✅ Password + key auth, keychain** |
| OS credential vault | ✅ | | ✅ | **Keychain / Secret Service / Credential Manager** |
| Workspace / folder hierarchy | ✅ query/dash folders + tags | ✅ (5.7+, local) | ❌ | **Multi-level tree + tags** |
| Changes queue (stage → commit) | | ✅ Apply/Discard | ✅ Safe mode | **Queue → Commit All** |
| Desktop shell size | Electron | Electron (~250 MB) | Native | **Tauri 2.0 (~40 MB)** |
| Open source | ❌ | ✅ GPLv3 | ❌ | **✅ Apache 2.0** |
*Notes: DB Pro is an Electron app (launched Nov 2025) whose marketing copy overclaims — its free plan caps connections/tabs/saved queries (FAQ inconsistently claims "unlimited local connections") and gates data imports + SSH tunneling to paid, though CSV/JSON export does work on the free tier; Beekeeper's free Community edition genuinely offers unlimited tabs/connections/queries but gates backup/restore, file import, multi-table export, ERD, AI, and premium DB connectors behind paid tiers; TablePlus's free tier includes every feature but caps you at 2 open tabs / 2 windows / 2 advanced filters.*
---
@@ -185,7 +192,7 @@ Gridline is built for developers and small teams who manage multiple database en
| :------------------ | :-------------------------------------------------------------------------------------------------------- | :----------------------------------------------- |
| **Desktop shell** | [Tauri 2.0](https://tauri.app) | Native webview container (~40 MB baseline) |
| **Backend** | Rust + [tokio](https://tokio.rs) | Async runtime, connection pooling, CLI execution |
| **DB drivers** | [sqlx](https://github.com/launchbadge/sqlx) / [tokio-postgres](https://github.com/sfackler/rust-postgres) | Pure-Rust PostgreSQL (SQLite via rusqlite) |
| **DB drivers** | [sqlx](https://github.com/launchbadge/sqlx) / [tokio-postgres](https://github.com/sfackler/rust-postgres) | PostgreSQL via tokio-postgres; MySQL via sqlx; SQLite via rusqlite |
| **CLI integration** | `std::process::Command` | Wraps system `pg_dump` / `pg_restore` |
| **Frontend** | [React 19](https://react.dev) + [TypeScript](https://www.typescriptlang.org) | Component-based UI |
| **Styling** | [Tailwind CSS](https://tailwindcss.com) | Utility-first, dark mode, glassmorphic design |
@@ -216,27 +223,27 @@ Code signing **will be added in the future** (Apple Developer Program + a Window
#### Which file should I download?
Each release contains **one file per platform** — you only need the one that matches your computer. (If a newer version is available, swap `0.6.0` for the version shown in the release title.)
Each release contains **one file per platform** — you only need the one that matches your computer. (If a newer version is available, swap `0.7.0` for the version shown in the release title.)
| Your system | Download this | Notes |
| :--- | :--- | :--- |
| macOS **Apple Silicon** (M1/M2/M3/M4…) | `Gridline_0.6.0_aarch64.dmg` | `aarch64` = Apple's own chip |
| macOS **Intel** | `Gridline_0.6.0_x64.dmg` | `x64` = Intel/AMD |
| **Windows** (most PCs) | `Gridline_0.6.0_x64-setup.exe` | The `.msi` is an alternate installer (for enterprises/IT admins) |
| **Debian / Ubuntu** | `Gridline_0.6.0_amd64.deb` | Install: `sudo apt install ./Gridline_0.6.0_amd64.deb` |
| **Fedora / RHEL / openSUSE** | `Gridline-0.6.0-1.x86_64.rpm` | Install: `sudo dnf install Gridline-0.6.0-1.x86_64.rpm` |
| **Any other Linux** | `Gridline_0.6.0_amd64.AppImage` | Works on every distro: `chmod +x` the file, then double-click it |
| macOS **Apple Silicon** (M1/M2/M3/M4…) | `Gridline_0.7.0_aarch64.dmg` | `aarch64` = Apple's own chip |
| macOS **Intel** | `Gridline_0.7.0_x64.dmg` | `x64` = Intel/AMD |
| **Windows** (most PCs) | `Gridline_0.7.0_x64-setup.exe` | The `.msi` is an alternate installer (for enterprises/IT admins) |
| **Debian / Ubuntu** | `Gridline_0.7.0_amd64.deb` | Install: `sudo apt install ./Gridline_0.7.0_amd64.deb` |
| **Fedora / RHEL / openSUSE** | `Gridline-0.7.0-1.x86_64.rpm` | Install: `sudo dnf install Gridline-0.7.0-1.x86_64.rpm` |
| **Any other Linux** | `Gridline_0.7.0_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.
#### How releases are made
Cutting a release is one command — CI builds everything. **Releases are cut from `main`, which is the production branch** — only push release tags from `main`, never from feature branches:
Cutting a release is one command — CI builds everything. **Releases are cut from `prod`, which is the production branch** — only push release tags from `prod`, never from feature branches:
```bash
git checkout main && git pull
git tag v0.6.0
git push origin v0.6.0
git checkout prod && git pull
git tag v0.7.0
git push origin v0.7.0
```
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**.
@@ -320,28 +327,17 @@ gridline/
## Roadmap
### ✅ Completed
The full plan — in-development (v0.7.0), next-up, queue, and shipped history — lives in **[ROADMAP.md](./ROADMAP.md)**.
- Tauri 2.0 + React 19 + TypeScript 5.8 project shell
- PostgreSQL and SQLite browse/query support
- Connection management with URI parser, SSH tunnels, TLS, OS keychain
- Workspace tree, folders, tags, favorites, recents
- Multi-tab DB viewer with virtualized grid, server-side filtering/sorting
- FK preview, JSON popover, inline cell editing, changes queue
- Query editor with Monaco, autocomplete, destructive-query guard
- Query history, saved queries, and Queries view
- Full PostgreSQL object explorer + schema visualizer
- Backup, restore, and DB-to-DB sync tools
- Settings redesign with theme, accent color, editor options
- Built-in **Gridline Demo (SQLite)** database
Highlights of what's next:
### 🔮 Future
- **Full Object Management** — CRUD on functions, triggers, sequences, enums, extensions, and views without the Query tab, plus schema CRUD, global object search, and copy-as-DDL
- **Full Redis support** — key browser, type-aware value editors, TTL management
- **More database types** — MariaDB, TimescaleDB, and friends
- **Managed DB support** — PlanetScale, Turso (Supabase/Neon presets shipped in v0.7.0)
- **AI integration (BYOK)** — natural-language → SQL, chat, summaries, charts
- **Multi-DB parity** — MySQL browsing, Redis key browser
- **Query workbench** — multiple result sets, visual query builder
- **Deeper PostgreSQL** — user/role management, replication views
- **Notebook reports** — SQL-backed markdown reports with embedded results
- **AI assistant (BYOK)** — bring-your-own-key natural-language → SQL, query explanations, and schema summaries
**[View the full roadmap →](./ROADMAP.md)**
---
@@ -366,7 +362,7 @@ gridline/
Contributions, bug reports, and feature ideas are welcome. Gridline is Apache 2.0-licensed and intentionally stays open — no paywalled tiers, no bundled proprietary services.
- Open a [GitHub Issue](https://github.com/adrianbonpin/gridline/issues/new) for bugs or ideas.
- Report a bug via the **[issue template](.github/ISSUE_TEMPLATE/bug_report.md)** — it walks you through environment details (OS, Gridline version, DB type/version, connection method) so we can reproduce issues quickly. Opening a [new issue](https://github.com/AdrianBonpin/gridline/issues/new) pre-fills the template automatically.
- Submit a pull request. Keep Tauri commands thin, type IPC boundaries explicitly, and follow the existing Rust/React conventions.
---
+145
View File
@@ -0,0 +1,145 @@
# Gridline Roadmap
Status legend: ✅ Shipped · 🏗️ In development · 🎯 Next up · 📋 In the queue · 🔮 Planned
This file is the **source of truth** for what Gridline is building. [AGENTS.md](./AGENTS.md) and [README.md](./README.md) link here — keep this current as priorities shift.
---
## ✅ Shipped (0.7.0)
- **New Connection screen revamp** — a single progressive flow: connection-string input + 2-column provider tab grid → expands into the full configuration form (label, tags/env/folder, General + SSH·SSL tabs). Removes the simple/detailed toggle.
- **Full MySQL DB viewer support** — connect (incl. SSL + SSH), browse databases/tables/columns/FKs, run queries, paginate, inline cell editing + changes queue (insert/update/delete/bulk/empty/drop), DDL copy (`SHOW CREATE TABLE`), CSV/JSON import.
- **DB viewer capability gating** — pure `dbCapabilities.ts` matrix per DB type; unsupported views show a clean "not supported" state instead of broken UI. Redis browsing explicitly gated off.
- **Supabase & NeonDB managed-PG presets** — provider cards with in-app setup instructions; persist as `postgresql` with an SSL hint.
- **SQLite file-path mode** — URI field becomes a file-path input with `Browse…`.
- **Tag overflow scroll** — connection cards show ≤3 tags, then scroll horizontally.
- **Input styling sweep** — `rounded-full``rounded-lg` on all form controls.
- **Version bump** 0.6.0 → **0.7.0**.
**Not in 0.7.0** (deferred, tracked below): Redis key browsing, MySQL Objects/ERD views, backup/restore/sync for MySQL + SQLite, multiple result sets, SSH key-file management, settings import/export, onboarding tour.
## 🎯 Next up
### Full Object Management (PostgreSQL)
Gridline can already **browse** every PostgreSQL object type (functions, triggers, sequences, enums, extensions, views, materialized views, procedures, indexes, constraints). Next up: full **CRUD** on those objects without ever touching the Query tab.
- Right-click any object → create / edit / drop with a generated-SQL preview before applying
- Enums: add/remove values, rename types
- Functions & procedures: edit signature + body, drop overloads by signature
- Triggers: create/edit/disable/enable, timing + event pickers
- Sequences: alter increment/start/min/max/cycle, restart
- Extensions: install/uninstall, schema reassignment
- Views & materialized views: edit definition, refresh matviews
- Indexes & constraints: create/drop per table with column pickers
- Staged through the changes queue (with confirmation) — never a surprise DDL
- **Out of scope this release:** the MySQL "Objects" view stays deferred (see In the queue) — this iteration is PostgreSQL-only
### Shipping alongside (companions)
- **Schema CRUD** — create/rename/drop schemas from the object tree
- **Global object search** — Cmd+K-style search across tables, functions, triggers, sequences, and extensions by name
- **Copy as DDL for any object** — `CREATE FUNCTION` / `CREATE TRIGGER` / … via the same DDL-generation used for edit/drop previews (also covers the "Table structure export" queue item)
- **Object dependencies** — `pg_depend`-based "what depends on this object?" view, shown before drops so nothing breaks silently
### Admin follow-up (after object management)
- **PostgreSQL users/roles + grants management** — create roles and set privileges from a UI (DB Pro has this at 0% on their roadmap — a differentiator to hold)
- **Maintenance actions** — right-click table → VACUUM / ANALYZE / REINDEX
## 📋 In the queue
### Full Redis Support
Connection + test work today; **key browsing is explicitly not part of 0.7.0** — it stays gated behind an "unsupported" state. Goal: first-class Redis like the PG/SQLite/MySQL viewers.
- Key browser (pattern search, filter by type, TTL display, key count)
- Value editors per type: string, list, hash, set, zset, stream, JSON
- Inline add / edit / delete keys, TTL management, flush
- Key expiry tracking and live refresh
### Additional Database Types
- **MariaDB** (wire-compatible with MySQL — should largely fall out of the 0.7.0 MySQL work)
- **TimescaleDB** (PostgreSQL extension — largely free once PG browsing is solid; surface hypertables/compression in the object tree)
- Candidates after that: CockroachDB, DuckDB, SQL Server, MongoDB (drivers are heavier lifts — revisit with demand)
### Managed Database Support (beyond Supabase / Neon)
Supabase and NeonDB presets shipped in v0.7.0. Remaining candidates:
- **PlanetScale** (Vitess/MySQL)
- **Turso** (libSQL)
- Provider detection guidance: paste a provider URL → Gridline auto-fills host/port/SSL mode; provider "connect" docs linked from the connection form
### Query Workbench Upgrades
- **Multiple result sets** — one query, multiple result tabs (stacked/scrollable) instead of only the last result *(deferred from 0.7.0)*
- **Cancel long-running queries** — per-connection cancel button (`pg_cancel_backend` and equivalents) instead of waiting or killing the app
- **Result streaming to file** — export 500k+ rows without loading them all into memory
- **Visual query builder** — drag-and-drop tables/joins/filters that generate SQL (TablePlus has one; DB Pro plans one)
### Schema & Data Tooling
- **SQLite `.dump` support** — match the pg_dump UX for SQLite
- **Schema diff / compare** — two-database structure diff that pairs naturally with DB-to-DB sync
- **MySQL Objects view + schema visualizer** — functions/triggers/sequences/enums/extensions browsing and ER diagram for MySQL *(deferred from 0.7.0 and out of scope for the object-management release — PostgreSQL-only for now)*
- **Backup/Restore/Sync for MySQL & SQLite** — pg_dump tooling is PostgreSQL-only today *(deferred from 0.7.0)*
- **More export formats** — Excel (.xlsx), JSONL, Parquet alongside CSV/JSON/SQL/Markdown
## 🔮 Planned
### AI Integration (BYOK)
Bring-your-own-key — no bundled model, no paywall, key stored in the OS keychain like DB passwords.
- Natural-language → SQL generation (schema-aware)
- Chat with your database (explain query results, error messages)
- Query explanations and schema summaries
- AI-generated charts from result sets
- Privacy-first: only the SQL/text you choose is sent to your provider; write-queries blocked by default, destructive actions confirmed before execution
### Website & Docs
- Landing page with screenshots, feature tour, and download links
- User documentation (connection setup, SSH/TLS, backup/sync, changes queue)
- Blog / changelog feed
### UI/UX Improvements (rolling)
Continuous polish, tracked as issues rather than one-off milestones:
- Empty states, error surfacing, and microcopy
- Keyboard shortcut audit + more configurable actions
- Performance passes on the grid, object tree, and large schemas
- Accessibility (contrast, focus states, screen-reader labels)
- Session restore — reopen tabs and query state from the last session
- Light-theme parity pass — dark is first-class; polish the light theme to match
Deferred from 0.7.0, slated for this bucket:
- **Onboarding tour** — first-run walkthrough built around the Gridline Demo database; contextual tooltips per screen
- **Settings export/import** — share theme, accent, editor options, page sizes, and defaults across machines (JSON file)
- **SSH key management** — read/generate key pairs and paste private keys directly in the SSH tab (today: path inputs only)
- **In-app changelog** — "What's new" panel fed from bundled release notes
---
## ✅ Shipped
- Tauri 2.0 + React 19 + TypeScript 5.8 project shell
- PostgreSQL and SQLite browse/query support
- Full MySQL DB viewer — connect, browse, query, edit + changes queue (v0.7.0)
- New Connection screen revamp — provider grid + Supabase/NeonDB managed-pg presets (v0.7.0)
- DB viewer capability gating + Redis unsupported state (v0.7.0)
- Connection management with URI parser, SSH tunnels, TLS, OS keychain
- Workspace tree, folders, tags, favorites, recents
- Multi-tab DB viewer with virtualized grid, server-side filtering/sorting
- FK preview, JSON popover, inline cell editing, changes queue
- Query editor with Monaco, autocomplete, destructive-query guard
- Query history, saved queries, and Queries view
- Full PostgreSQL object explorer + schema visualizer
- Backup, restore, and DB-to-DB sync tools
- Settings redesign with theme, accent color, editor options
- Built-in **Gridline Demo (SQLite)** database
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "gridline",
"private": true,
"version": "0.6.0",
"version": "0.7.0",
"description": "An open-source, high-performance database GUI client for PostgreSQL and beyond",
"type": "module",
"scripts": {
-6
View File
@@ -1,6 +0,0 @@
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

+1 -1
View File
@@ -1783,7 +1783,7 @@ dependencies = [
[[package]]
name = "gridline"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"chrono",
"deadpool-postgres",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "gridline"
version = "0.6.0"
version = "0.7.0"
description = "An open-source, high-performance database GUI client for PostgreSQL and beyond"
authors = ["you"]
edition = "2021"
@@ -31,7 +31,7 @@ tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
tokio = { version = "1", features = ["full"] }
tokio-postgres = { version = "0.7", features = ["with-chrono-0_4", "with-uuid-1", "with-serde_json-1"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "mysql", "tls-rustls"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "mysql", "tls-rustls", "json"] }
redis = { version = "0.27", features = ["tokio-comp"] }
# async-ssh2 is not available at v0.4; using synchronous ssh2 via tokio::task::spawn_blocking per plan's fallback
ssh2 = { version = "0.9" }
+700 -17
View File
@@ -3,12 +3,13 @@
//! This module provides pure SQL builder functions, pagination helpers,
//! and Tauri commands for the database viewer.
use crate::db::pool::{DbConfig, DbHandle};
use crate::db::pool::{ConnectionPoolManager, DbConfig, DbHandle};
use crate::models::db_viewer::{
Change, ColumnInfo, ConstraintInfo, EnumInfo, ExtensionInfo, FunctionInfo, IndexInfo,
QueryResult, SequenceInfo, TableInfo, TriggerInfo,
};
use std::collections::HashMap;
use sqlx::Row;
use tauri::State;
use tokio_postgres::types::ToSql;
@@ -728,7 +729,7 @@ pub async fn apply_bulk_insert_pg(
/// Sanitize a raw error string before it crosses the IPC boundary: redact
/// credential-like fragments (connection URLs, `password=...`) and cap length.
fn sanitize_error(e: &str) -> String {
pub(crate) fn sanitize_error(e: &str) -> String {
truncate(&redact_secrets(e), 400)
}
@@ -878,6 +879,117 @@ where
Ok((client, handle))
}
/// Headless MySQL connect (no Tauri `State`). Opens an SSH tunnel when
/// configured (binding 127.0.0.1 only), maps SSL modes, and registers a
/// `DbHandle::MySql` pool. Errors are sanitized so no `mysql://user:pass@host`
/// text leaks across the IPC boundary.
pub(crate) async fn run_mysql_connect(
connection_id: &str,
config: &crate::db::pool::DbConfig,
ssh_manager: &std::sync::Mutex<crate::commands::ssh::SshTunnelManager>,
pool_manager: &tokio::sync::Mutex<crate::db::pool::ConnectionPoolManager>,
) -> Result<(), String> {
use sqlx::mysql::{MySqlConnectOptions, MySqlPoolOptions, MySqlSslMode};
if config.host.trim().is_empty() {
return Err("host is required".to_string());
}
let target_host: String;
let target_port: u16;
let via_tunnel: bool;
if let Some(ssh_cfg) = config.ssh_config() {
let key = connection_id.to_string();
let remote_host = config.host.clone();
let remote_port = config.port.unwrap_or(3306) as u16;
let pw = config.ssh_password.clone();
let pp = config.ssh_passphrase.clone();
let backend = ssh_manager.lock().unwrap().backend_clone();
let tunnel = tokio::task::spawn_blocking(move || {
backend.open(
&key,
&ssh_cfg,
&remote_host,
remote_port,
pw.as_deref(),
pp.as_deref(),
)
})
.await
.map_err(|e| format!("Connection failed: {e}"))?
.map_err(|e| sanitize_error(&e))?;
let lp = tunnel.local_port;
ssh_manager
.lock()
.unwrap()
.insert_tunnel(connection_id.to_string(), tunnel);
target_host = "127.0.0.1".to_string();
target_port = lp;
via_tunnel = true;
} else {
target_host = config.host.clone();
target_port = config.port.unwrap_or(3306) as u16;
via_tunnel = false;
}
let mut opts = MySqlConnectOptions::new()
.host(&target_host)
.port(target_port)
.username(config.username.as_deref().unwrap_or("root"))
.password(config.password.as_deref().unwrap_or(""))
.database(config.database.as_deref().unwrap_or("mysql"));
// TLS: through a tunnel the peer is loopback, so verify-ca/verify-full
// degrade to encrypt-only `require`. Direct connections honor the mode.
let decision = crate::commands::ssh::effective_tls_decision(
crate::db::tls::tls_decision(config.ssl_mode.as_deref()),
via_tunnel,
);
match decision {
crate::db::tls::TlsDecision::Disable => {
opts = opts.ssl_mode(MySqlSslMode::Disabled);
}
crate::db::tls::TlsDecision::Require => {
opts = opts.ssl_mode(MySqlSslMode::Required);
}
crate::db::tls::TlsDecision::Verify => {
// sqlx 0.8 has no VerifyFull: verify-ca -> VerifyCa (chain only),
// verify-full -> VerifyIdentity (chain + hostname).
match config.ssl_mode.as_deref() {
Some("verify-ca") => opts = opts.ssl_mode(MySqlSslMode::VerifyCa),
_ => opts = opts.ssl_mode(MySqlSslMode::VerifyIdentity),
}
if let Some(ca) = config.ssl_ca_path.as_deref() {
opts = opts.ssl_ca(ca);
}
}
}
match MySqlPoolOptions::new()
.max_connections(5)
.acquire_timeout(std::time::Duration::from_secs(10))
.connect_with(opts)
.await
{
Ok(pool) => {
pool_manager
.lock()
.await
.register(connection_id, crate::db::pool::DbHandle::MySql(pool));
Ok(())
}
Err(e) => {
if via_tunnel {
ssh_manager.lock().unwrap().close_tunnel(connection_id);
}
Err(format!(
"Connection failed: {}",
sanitize_error(&format!("{e}"))
))
}
}
}
#[tauri::command]
pub async fn db_connect(
connection_id: String,
@@ -992,6 +1104,14 @@ pub async fn db_connect(
}
Err(e) => Err(format!("Connection failed: {}", e)),
}
} else if config.db_type == "mysql" {
run_mysql_connect(
&connection_id,
&config,
&state.ssh_manager,
&state.pool_manager,
)
.await
} else {
Err(format!(
"Database type '{}' not yet supported for DB viewer",
@@ -1031,6 +1151,19 @@ pub async fn get_databases(
// SQLite has a single database per file; expose the catalog name.
Ok(vec!["main".to_string()])
}
Some(crate::db::pool::DbHandle::MySql(pool)) => {
let pool = &*pool; // Executor is implemented for &Pool, not &mut Pool
let rows = sqlx::query(&crate::db::mysql::mysql_databases_query())
.fetch_all(pool)
.await
.map_err(|e| sanitize_error(&format!("{e}")))?;
let mut dbs: Vec<String> = rows
.iter()
.map(|r| crate::db::mysql::mysql_row_string(r, 0))
.collect();
dbs.retain(|d| !crate::db::mysql::MYSQL_SYSTEM_DBS.contains(&d.as_str()));
Ok(dbs)
}
None => Err("Connection not found".to_string()),
}
}
@@ -1061,20 +1194,36 @@ pub async fn get_schemas(
.map_err(|e| e.to_string())?;
Ok(rows.filter_map(|r| r.ok()).collect())
}
Some(crate::db::pool::DbHandle::MySql(pool)) => {
let pool = &*pool; // Executor is implemented for &Pool, not &mut Pool
// MySQL has no separate schema layer — databases play the role of
// schemas, so the schema selector mirrors the database list.
let rows = sqlx::query(&crate::db::mysql::mysql_databases_query())
.fetch_all(pool)
.await
.map_err(|e| sanitize_error(&format!("{e}")))?;
let mut dbs: Vec<String> = rows
.iter()
.map(|r| crate::db::mysql::mysql_row_string(r, 0))
.collect();
dbs.retain(|d| !crate::db::mysql::MYSQL_SYSTEM_DBS.contains(&d.as_str()));
Ok(dbs)
}
None => Err("Connection not found".to_string()),
}
}
#[tauri::command]
pub async fn get_tables(
connection_id: String,
schema: Option<String>,
state: State<'_, crate::AppState>,
/// Headless table listing shared by the `get_tables` command and integration
/// tests (thin-command principle — no Tauri `State`).
pub(crate) async fn get_tables_inner(
pool_manager: &tokio::sync::Mutex<ConnectionPoolManager>,
connection_id: &str,
schema: Option<&str>,
) -> Result<Vec<TableInfo>, String> {
let mut pm = state.pool_manager.lock().await;
match pm.get(&connection_id) {
let mut pm = pool_manager.lock().await;
match pm.get(connection_id) {
Some(crate::db::pool::DbHandle::Postgresql(client, _)) => {
let schema_filter = schema.unwrap_or_else(|| "public".to_string());
let schema_filter = schema.unwrap_or("public").to_string();
let rows = client
.query(
"SELECT table_name, table_schema, table_type FROM information_schema.tables WHERE table_schema = $1 AND table_type IN ('BASE TABLE', 'VIEW') ORDER BY table_name",
@@ -1109,20 +1258,151 @@ pub async fn get_tables(
rows.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())
}
Some(crate::db::pool::DbHandle::MySql(pool)) => {
let pool = &*pool; // Executor is implemented for &Pool, not &mut Pool
// 2 columns (name, type) when a schema is given; 3 (+ schema)
// when not — hence sqlx::query + try_get, not query_as.
let query = crate::db::introspection::mysql_tables_query(schema);
let rows = sqlx::query(&query)
.fetch_all(pool)
.await
.map_err(|e| sanitize_error(&format!("{e}")))?;
Ok(rows
.iter()
.map(|r| {
let name = crate::db::mysql::mysql_row_string(r, 0);
let raw_type = crate::db::mysql::mysql_row_string(r, 1);
let schema_name: String = match schema {
Some(s) => s.to_string(),
None => crate::db::mysql::mysql_row_string(r, 2),
};
let table_type = if raw_type.eq_ignore_ascii_case("view") {
"VIEW"
} else {
"TABLE"
};
TableInfo {
name,
schema: schema_name,
table_type: table_type.to_string(),
}
})
.collect())
}
None => Err("Connection not found".to_string()),
}
}
#[tauri::command]
pub async fn get_table_data(
pub async fn get_tables(
connection_id: String,
schema: String,
table: String,
schema: Option<String>,
state: State<'_, crate::AppState>,
) -> Result<Vec<TableInfo>, String> {
get_tables_inner(&state.pool_manager, &connection_id, schema.as_deref()).await
}
/// Load column metadata (name, type, nullability, PK, default, generated) for
/// a MySQL table, then mark FK columns with their referenced (table, column).
///
/// PK and generated columns are read-only (`editable: false`), mirroring the
/// PostgreSQL rule. `fk_ref` carries the referenced table + column only — the
/// same contract the frontend expects (MySQL FKs are assumed to live in the
/// same database, matching how PostgreSQL's `fk_ref` assumes the same schema).
pub(crate) async fn mysql_load_columns(
pool: &sqlx::MySqlPool,
schema: &str,
table: &str,
) -> Result<Vec<ColumnInfo>, String> {
let col_query = crate::db::mysql::mysql_columns_query(schema, table);
let col_rows = sqlx::query(&col_query)
.fetch_all(pool)
.await
.map_err(|e| sanitize_error(&format!("{e}")))?;
let mut columns: Vec<ColumnInfo> = col_rows
.iter()
.map(|r| {
let name = crate::db::mysql::mysql_row_string(r, 0);
let data_type = crate::db::mysql::mysql_row_string(r, 1);
let is_nullable = crate::db::mysql::mysql_row_string(r, 2);
let column_key = crate::db::mysql::mysql_row_string(r, 3);
let default_value: Option<String> = r
.try_get::<String, _>(4)
.ok()
.or_else(|| r.try_get::<Vec<u8>, _>(4).ok().map(|b| String::from_utf8_lossy(&b).into_owned()));
let extra = crate::db::mysql::mysql_row_string(r, 5);
let is_pk = column_key == "PRI";
let is_generated = extra.to_ascii_uppercase().contains("GENERATED");
ColumnInfo {
name: name.clone(),
data_type,
is_nullable: is_nullable == "YES",
is_pk,
is_fk: false,
fk_ref: None,
default_value,
editable: !is_pk && !is_generated,
is_generated,
}
})
.collect();
// FK pass: mark is_fk + fk_ref for columns named in the FK metadata.
let fk_query = crate::db::mysql::mysql_fk_query(schema, table);
let fk_rows = sqlx::query(&fk_query)
.fetch_all(pool)
.await
.map_err(|e| sanitize_error(&format!("{e}")))?;
for r in fk_rows {
let col = crate::db::mysql::mysql_row_string(&r, 0);
let ref_table = crate::db::mysql::mysql_row_string(&r, 2);
let ref_col = crate::db::mysql::mysql_row_string(&r, 3);
if let Some(c) = columns.iter_mut().find(|c| c.name == col) {
c.is_fk = true;
c.fk_ref = Some((ref_table, ref_col));
}
}
Ok(columns)
}
/// Bind `serde_json::Value` change params to a MySQL `?`-placeholder statement.
/// Maps common JSON types to native MySQL-encodable values (NULL → SQL NULL).
pub(crate) fn bind_mysql_params<'q>(
q: sqlx::query::Query<'q, sqlx::MySql, sqlx::mysql::MySqlArguments>,
params: &[serde_json::Value],
) -> sqlx::query::Query<'q, sqlx::MySql, sqlx::mysql::MySqlArguments> {
let mut q = q;
for v in params {
match v {
serde_json::Value::Null => q = q.bind(Option::<String>::None),
serde_json::Value::Bool(b) => q = q.bind(*b),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
q = q.bind(i);
} else if let Some(f) = n.as_f64() {
q = q.bind(f);
} else {
q = q.bind(n.to_string());
}
}
serde_json::Value::String(s) => q = q.bind(s.clone()),
other => q = q.bind(other.to_string()),
}
}
q
}
/// Headless table-data fetch shared by the `get_table_data` command and
/// integration tests (thin-command principle — no Tauri `State`).
pub(crate) async fn get_table_data_inner(
pool_manager: &tokio::sync::Mutex<ConnectionPoolManager>,
connection_id: &str,
schema: &str,
table: &str,
page: Option<i64>,
page_size: Option<i64>,
filters: Option<Vec<crate::models::db_viewer::FilterRule>>,
sorts: Option<Vec<crate::models::db_viewer::SortRule>>,
state: State<'_, crate::AppState>,
) -> Result<QueryResult, String> {
let p = page.unwrap_or(1);
let ps = page_size.unwrap_or(50);
@@ -1130,8 +1410,8 @@ pub async fn get_table_data(
let filters = filters.unwrap_or_default();
let sorts = sorts.unwrap_or_default();
let mut pm = state.pool_manager.lock().await;
match pm.get(&connection_id) {
let mut pm = pool_manager.lock().await;
match pm.get(connection_id) {
Some(crate::db::pool::DbHandle::Postgresql(client, _)) => {
// Build filter clause (shared by COUNT and data queries)
let mut pg_param_idx: usize = 0;
@@ -1359,7 +1639,7 @@ ORDER BY c.ordinal_position"#;
let is_view: bool = conn
.query_row(
"SELECT type = 'view' FROM sqlite_master WHERE name = ?1 AND type IN ('table', 'view')",
[&table],
rusqlite::params![table],
|r| r.get::<_, bool>(0),
)
.unwrap_or(false);
@@ -1499,10 +1779,88 @@ ORDER BY c.ordinal_position"#;
execution_time_ms: None,
})
}
Some(crate::db::pool::DbHandle::MySql(pool)) => {
let pool = &*pool; // Executor is implemented for &Pool, not &mut Pool
let columns = mysql_load_columns(pool, schema, table).await?;
// Total count (filters do not affect the count — MySQL has no
// per-filter count query, mirroring the select builder's scope).
let total_rows: i64 =
sqlx::query_scalar::<_, i64>(&crate::db::mysql::mysql_count_query(schema, table))
.fetch_one(pool)
.await
.map_err(|e| sanitize_error(&format!("{e}")))?;
// mysql_select_data_query already embeds ORDER BY from `sorts`
// (falling back to a smart default sort) — no shared order-clause
// helper needed.
let visible_names: Vec<String> = columns.iter().map(|c| c.name.clone()).collect();
let default_sort = crate::db::mysql::mysql_default_sort(&visible_names).to_string();
let data_query = crate::db::mysql::mysql_select_data_query(
schema, table, &filters, &sorts, &default_sort,
);
let mut q = sqlx::query(&data_query);
for f in &filters {
// null/notnull operators emit IS [NOT] NULL — no bound param.
if f.operator == "null" || f.operator == "notnull" {
continue;
}
q = q.bind(&f.value);
}
let data_rows = q
.bind(ps)
.bind(off)
.fetch_all(pool)
.await
.map_err(|e| sanitize_error(&format!("{e}")))?;
let rows: Vec<Vec<serde_json::Value>> = data_rows
.iter()
.map(|row| {
(0..row.len())
.map(|i| crate::commands::query::mysql_cell_to_json(row, i))
.collect()
})
.collect();
Ok(QueryResult {
columns,
rows,
total_rows,
page: p,
page_size: ps,
execution_time_ms: None,
})
}
None => Err("Connection not found".to_string()),
}
}
#[tauri::command]
pub async fn get_table_data(
connection_id: String,
schema: String,
table: String,
page: Option<i64>,
page_size: Option<i64>,
filters: Option<Vec<crate::models::db_viewer::FilterRule>>,
sorts: Option<Vec<crate::models::db_viewer::SortRule>>,
state: State<'_, crate::AppState>,
) -> Result<QueryResult, String> {
get_table_data_inner(
&state.pool_manager,
&connection_id,
&schema,
&table,
page,
page_size,
filters,
sorts,
)
.await
}
#[tauri::command]
pub async fn get_fk_preview(
connection_id: String,
@@ -1697,6 +2055,44 @@ ORDER BY c.ordinal_position"#;
execution_time_ms: None,
})
}
Some(crate::db::pool::DbHandle::MySql(pool)) => {
let pool = &*pool; // Executor is implemented for &Pool, not &mut Pool
// The request already resolves the referenced table/column (the
// frontend reads `fk_ref`); fetch the referenced row directly.
let columns = mysql_load_columns(pool, &schema, &table).await?;
let qualified = format!(
"{}.{}",
crate::db::mysql::mysql_quote_ident(&schema),
crate::db::mysql::mysql_quote_ident(&table)
);
let data_query = format!(
"SELECT * FROM {} WHERE {} = ? LIMIT 1",
qualified,
crate::db::mysql::mysql_quote_ident(&column)
);
let data_rows = sqlx::query(&data_query)
.bind(&value)
.fetch_all(pool)
.await
.map_err(|e| sanitize_error(&format!("{e}")))?;
let rows: Vec<Vec<serde_json::Value>> = data_rows
.iter()
.map(|row| {
(0..row.len())
.map(|i| crate::commands::query::mysql_cell_to_json(row, i))
.collect()
})
.collect();
let total_rows = rows.len() as i64;
Ok(QueryResult {
columns,
rows,
total_rows,
page: 1,
page_size: 1,
execution_time_ms: None,
})
}
None => Err("Connection not found".to_string()),
}
}
@@ -1880,6 +2276,97 @@ pub async fn execute_change(
}
Ok(())
}
Some(crate::db::pool::DbHandle::MySql(pool)) => {
let pool = &*pool; // Executor is implemented for &Pool, not &mut Pool
let (sql, params): (String, Vec<serde_json::Value>) = match &change {
Change::Update {
schema,
table,
primary_key,
new_data,
..
} => {
let pk = parse_json_pairs(primary_key)?;
let data = parse_json_pairs(new_data)?;
crate::db::mysql::mysql_build_update_sql(schema, table, &pk, &data)?
}
Change::Insert {
schema,
table,
data,
..
} => {
let pairs = parse_json_pairs(data)?;
crate::db::mysql::mysql_build_insert_sql(schema, table, &pairs)
}
Change::Delete {
schema,
table,
primary_key,
..
} => {
let pk = parse_json_pairs(primary_key)?;
crate::db::mysql::mysql_build_delete_sql(schema, table, &pk)?
}
Change::AlterTable { sql, .. } => {
// Raw DDL, no bound parameters.
sqlx::query(sql)
.execute(pool)
.await
.map_err(|e| sanitize_error(&format!("{e}")))?;
return Ok(());
}
Change::BulkInsert {
schema,
table,
columns,
rows,
..
} => {
if columns.is_empty() || rows.is_empty() {
return Err("bulk insert requires non-empty columns and rows".to_string());
}
let sql = crate::db::mysql::mysql_build_bulk_insert_sql(
schema,
table,
columns,
rows.len(),
);
let params: Vec<serde_json::Value> =
rows.iter().flatten().cloned().collect();
// Bulk insert affects many rows — skip the single-row
// affected-count guard.
bind_mysql_params(sqlx::query(&sql), &params)
.execute(pool)
.await
.map_err(|e| sanitize_error(&format!("{e}")))?;
return Ok(());
}
Change::DropTable { schema, table, .. } => {
sqlx::query(&crate::db::mysql::mysql_build_drop_sql(schema, table))
.execute(pool)
.await
.map_err(|e| sanitize_error(&format!("{e}")))?;
return Ok(());
}
Change::EmptyTable { schema, table, .. } => {
sqlx::query(&crate::db::mysql::mysql_build_empty_sql(schema, table))
.execute(pool)
.await
.map_err(|e| sanitize_error(&format!("{e}")))?;
return Ok(());
}
};
let result = bind_mysql_params(sqlx::query(&sql), &params)
.execute(pool)
.await
.map_err(|e| sanitize_error(&format!("{e}")))?;
if let Some(msg) = affected_count_error(result.rows_affected()) {
return Err(msg);
}
Ok(())
}
None => Err("Connection not found".to_string()),
}
}
@@ -1906,6 +2393,16 @@ pub async fn refresh_connection(
.map_err(|e| e.to_string())?;
Ok(())
}
Some(crate::db::pool::DbHandle::MySql(pool)) => {
let pool = &*pool; // Executor is implemented for &Pool, not &mut Pool
// Verify reachability (the sqlx pool reconnects transparently); the
// frontend re-issues getDatabases/getSchemas/getTables afterwards.
sqlx::query_scalar::<_, i64>("SELECT 1")
.fetch_one(pool)
.await
.map_err(|e| sanitize_error(&format!("{e}")))?;
Ok(())
}
None => Err("Connection not found".to_string()),
}
}
@@ -1941,6 +2438,7 @@ pub async fn get_functions(
.collect())
}
Some(DbHandle::Sqlite(_)) => Ok(vec![]),
Some(DbHandle::MySql(_)) => Err("MySQL functions not yet supported".to_string()),
None => Err("Connection not found".into()),
}
}
@@ -1976,6 +2474,7 @@ pub async fn get_indexes(
.collect())
}
Some(DbHandle::Sqlite(_)) => Ok(vec![]),
Some(DbHandle::MySql(_)) => Err("MySQL indexes not yet supported".to_string()),
None => Err("Connection not found".into()),
}
}
@@ -2023,6 +2522,7 @@ pub async fn get_constraints(
.collect())
}
Some(DbHandle::Sqlite(_)) => Ok(vec![]),
Some(DbHandle::MySql(_)) => Err("MySQL constraints not yet supported".to_string()),
None => Err("Connection not found".into()),
}
}
@@ -2058,6 +2558,7 @@ pub async fn get_triggers(
.collect())
}
Some(DbHandle::Sqlite(_)) => Ok(vec![]),
Some(DbHandle::MySql(_)) => Err("MySQL triggers not yet supported".to_string()),
None => Err("Connection not found".into()),
}
}
@@ -2095,6 +2596,7 @@ pub async fn get_sequences(
.collect())
}
Some(DbHandle::Sqlite(_)) => Ok(vec![]),
Some(DbHandle::MySql(_)) => Err("MySQL sequences not yet supported".to_string()),
None => Err("Connection not found".into()),
}
}
@@ -2124,6 +2626,7 @@ pub async fn get_enums(
.collect())
}
Some(DbHandle::Sqlite(_)) => Ok(vec![]),
Some(DbHandle::MySql(_)) => Err("MySQL enums not yet supported".to_string()),
None => Err("Connection not found".into()),
}
}
@@ -2149,6 +2652,7 @@ pub async fn get_extensions(
.collect())
}
Some(DbHandle::Sqlite(_)) => Ok(vec![]),
Some(DbHandle::MySql(_)) => Err("MySQL extensions not yet supported".to_string()),
None => Err("Connection not found".into()),
}
}
@@ -2169,6 +2673,15 @@ pub async fn get_table_ddl(
let mut pm = state.pool_manager.lock().await;
match pm.get(&connection_id) {
Some(DbHandle::Sqlite(conn)) => get_sqlite_ddl(conn, &table),
Some(DbHandle::MySql(pool)) => {
let pool = &*pool; // Executor is implemented for &Pool, not &mut Pool
// SHOW CREATE TABLE returns (Table, Create Table); take the DDL.
let row = sqlx::query(&crate::db::mysql::mysql_ddl_query(&schema, &table))
.fetch_one(pool)
.await
.map_err(|e| sanitize_error(&format!("{e}")))?;
Ok(crate::db::mysql::mysql_row_string(&row, 1))
}
Some(DbHandle::Postgresql(_client, _)) => {
// Pull connection metadata so pg_dump reaches the same server the
// pool is connected to (host/port/user/dbname + keychain password).
@@ -2223,7 +2736,10 @@ pub async fn get_table_ddl(
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::ssh::{Ssh2Backend, SshTunnelManager};
use crate::db::pool::{ConnectionPoolManager, DbConfig};
use crate::models::db_viewer::Change;
use std::sync::{Arc, Mutex as StdMutex};
#[test]
fn split_columns_csv_handles_commas_and_trims() {
@@ -2636,4 +3152,171 @@ mod tests {
Some("ambiguous row match".to_string())
);
}
async fn fresh_pool_manager() -> tokio::sync::Mutex<ConnectionPoolManager> {
tokio::sync::Mutex::new(ConnectionPoolManager::new())
}
#[tokio::test]
async fn run_mysql_connect_rejects_empty_host() {
let cfg = DbConfig {
db_type: "mysql".into(),
host: "".into(),
port: Some(3306),
username: Some("root".into()),
password: None,
database: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
ssh_host: None,
ssh_port: None,
ssh_user: None,
ssh_auth_method: None,
ssh_password: None,
ssh_private_key_path: None,
ssh_passphrase: None,
};
let ssh = StdMutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend)));
let pm = fresh_pool_manager().await;
let id = "mysql-empty-host";
let res = run_mysql_connect(id, &cfg, &ssh, &pm).await;
assert!(res.is_err(), "empty host must fail before any network call");
let mut pmg = pm.lock().await;
assert!(pmg.get(id).is_none(), "no handle registered on failure");
}
// -----------------------------------------------------------------------
// MySQL helpers + builders (Task 2.5)
// -----------------------------------------------------------------------
#[test]
fn mysql_databases_query_is_show_databases() {
assert_eq!(crate::db::mysql::mysql_databases_query(), "SHOW DATABASES");
}
#[test]
fn mysql_system_dbs_are_filtered() {
let all = vec!["information_schema", "mysql", "performance_schema", "sys", "shop"];
let filtered: Vec<&str> = all
.into_iter()
.filter(|d| !crate::db::mysql::MYSQL_SYSTEM_DBS.contains(d))
.collect();
assert_eq!(filtered, vec!["shop"]);
}
#[test]
fn mysql_execute_change_builds_update_sql() {
let pk = vec![("id".to_string(), serde_json::json!(1))];
let data = vec![("name".to_string(), serde_json::json!("x"))];
let (sql, _params) =
crate::db::mysql::mysql_build_update_sql("shop", "orders", &pk, &data).unwrap();
assert_eq!(sql, "UPDATE `shop`.`orders` SET `name` = ? WHERE `id` = ? LIMIT 1");
}
#[tokio::test]
async fn run_mysql_connect_registers_handle_when_lazy_url_parses() {
// No live connection: a deliberately unreachable host with a short timeout.
// We assert the function returns an Err (not a panic) and registers nothing.
let cfg = DbConfig {
db_type: "mysql".into(),
host: "127.0.0.1".into(),
port: Some(1),
username: Some("root".into()),
password: Some("x".into()),
database: Some("mysql".into()),
ssl_mode: Some("require".into()),
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
ssh_host: None,
ssh_port: None,
ssh_user: None,
ssh_auth_method: None,
ssh_password: None,
ssh_private_key_path: None,
ssh_passphrase: None,
};
let ssh = StdMutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend)));
let pm = fresh_pool_manager().await;
let id = "mysql-unreachable";
let res = run_mysql_connect(id, &cfg, &ssh, &pm).await;
assert!(
res.is_err(),
"port 1 should refuse; must be a clean Err, not panic"
);
assert!(pm.lock().await.get(id).is_none());
}
/// Live MySQL integration test — env-gated via `GRIDLINE_TEST_MYSQL_*`.
/// Browsing + pagination over a real server; returns early (Ok) when the
/// env vars are absent, so the `#[ignore]` gate is the only way it runs.
#[tokio::test]
#[ignore]
async fn mysql_integration_browse_and_edit() {
let host = match std::env::var("GRIDLINE_TEST_MYSQL_HOST") {
Ok(v) => v,
Err(_) => return,
};
let port: i64 = std::env::var("GRIDLINE_TEST_MYSQL_PORT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(3306);
let user = std::env::var("GRIDLINE_TEST_MYSQL_USER").unwrap_or_else(|_| "root".into());
let pass = std::env::var("GRIDLINE_TEST_MYSQL_PASS").unwrap_or_default();
let db = match std::env::var("GRIDLINE_TEST_MYSQL_DB") {
Ok(v) => v,
Err(_) => return,
};
let cfg = DbConfig {
db_type: "mysql".into(),
host,
port: Some(port),
username: Some(user),
password: Some(pass),
database: Some(db.clone()),
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
ssh_host: None,
ssh_port: None,
ssh_user: None,
ssh_auth_method: None,
ssh_password: None,
ssh_private_key_path: None,
ssh_passphrase: None,
};
let ssh = StdMutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend)));
let pm = fresh_pool_manager().await;
let id = format!("mysql-it-{}", uuid::Uuid::new_v4());
run_mysql_connect(&id, &cfg, &ssh, &pm).await.expect("connect");
let tables = get_tables_inner(&pm, &id, Some(&db)).await.expect("tables");
assert!(!tables.is_empty(), "test DB must contain at least one table");
let first = &tables[0];
let data = get_table_data_inner(
&pm,
&id,
&first.schema,
&first.name,
Some(1),
Some(10),
None,
None,
)
.await
.expect("data");
assert_eq!(data.page, 1);
assert_eq!(data.page_size, 10);
// Columns must be resolvable through the shared helper.
let pool = match pm.lock().await.get(&id) {
Some(crate::db::pool::DbHandle::MySql(p)) => p.clone(),
_ => panic!("mysql handle missing"),
};
let cols = mysql_load_columns(&pool, &first.schema, &first.name)
.await
.expect("columns");
assert_eq!(cols.len(), data.columns.len());
}
}
+178
View File
@@ -12,6 +12,7 @@
use crate::db::pool::DbHandle;
use crate::models::db_viewer::{ColumnInfo, QueryResult};
use serde::{Deserialize, Serialize};
use sqlx::{Column, Row};
use std::time::Instant;
use tauri::State;
use uuid::Uuid;
@@ -100,6 +101,7 @@ pub(crate) async fn execute_query_inner(
execute_pg_query(client, query, page, page_size).await
}
Some(DbHandle::Sqlite(conn)) => execute_sqlite_query(conn, query, page, page_size),
Some(DbHandle::MySql(pool)) => execute_mysql_query(pool, query, page, page_size).await,
None => {
let elapsed = start.elapsed().as_millis() as i64;
let err = "Connection not found".to_string();
@@ -495,6 +497,164 @@ fn sqlite_value_to_json(row: &rusqlite::Row, i: usize) -> serde_json::Value {
}
}
// ---------------------------------------------------------------------------
// MySQL execution
// ---------------------------------------------------------------------------
/// Wrap a user query for pagination: `SELECT * FROM (<q>) AS _gridline_data LIMIT ? OFFSET ?`.
pub(crate) fn mysql_wrap_data(query: &str) -> String {
format!(
"SELECT * FROM ({}) AS _gridline_data LIMIT ? OFFSET ?",
query.trim()
)
}
/// Wrap a user query for counting: `SELECT COUNT(*) FROM (<q>) AS _gridline_cnt`.
pub(crate) fn mysql_wrap_count(query: &str) -> String {
format!("SELECT COUNT(*) FROM ({}) AS _gridline_cnt", query.trim())
}
/// Convert a sqlx MySql row cell to serde_json::Value (via the `json` feature).
/// Shared with the DB-viewer commands (pub(crate)).
pub(crate) fn mysql_cell_to_json(row: &sqlx::mysql::MySqlRow, i: usize) -> serde_json::Value {
if let Ok(Some(v)) = row.try_get::<Option<serde_json::Value>, _>(i) {
return v;
}
if let Ok(s) = row.try_get::<Option<String>, _>(i) {
return s
.map(|s| serde_json::Value::String(s))
.unwrap_or(serde_json::Value::Null);
}
if let Ok(b) = row.try_get::<Option<Vec<u8>>, _>(i) {
return b
.map(|b| serde_json::Value::String(String::from_utf8_lossy(&b).into_owned()))
.unwrap_or(serde_json::Value::Null);
}
serde_json::Value::Null
}
async fn execute_mysql_query(
pool: &sqlx::MySqlPool,
query: &str,
page: i64,
page_size: i64,
) -> Result<QueryResult, String> {
let trimmed = query.trim();
if trimmed.is_empty() {
return Err("Query cannot be empty".to_string());
}
let off = (page.saturating_sub(1).max(0)) * page_size;
// Try the wrapped count first; fall back to raw on failure.
let total_rows: i64 = match sqlx::query_scalar::<_, i64>(&mysql_wrap_count(trimmed))
.fetch_one(pool)
.await
{
Ok(n) => n,
Err(_) => return execute_mysql_raw(pool, trimmed, page, page_size, off).await,
};
let data_rows = match sqlx::query(&mysql_wrap_data(trimmed))
.bind(page_size)
.bind(off)
.fetch_all(pool)
.await
{
Ok(rows) => rows,
Err(_) => return execute_mysql_raw(pool, trimmed, page, page_size, off).await,
};
let columns: Vec<ColumnInfo> = match data_rows.first() {
Some(first) => first
.columns()
.iter()
.map(|c| ColumnInfo {
name: c.name().to_string(),
data_type: c.type_info().to_string(),
is_nullable: true,
is_pk: false,
is_fk: false,
fk_ref: None,
default_value: None,
editable: true,
is_generated: false,
})
.collect(),
None => return execute_mysql_raw(pool, trimmed, page, page_size, off).await,
};
let rows: Vec<Vec<serde_json::Value>> = data_rows
.iter()
.map(|r| (0..r.len()).map(|i| mysql_cell_to_json(r, i)).collect())
.collect();
Ok(QueryResult {
columns,
rows,
total_rows,
page,
page_size,
execution_time_ms: None,
})
}
/// Raw fallback (no wrapping). Runs the query as-is and slices client-side,
/// mirroring the PG `simple_query` raw path. Used when wrapping fails
/// (e.g., multi-statement or non-selectable SQL).
async fn execute_mysql_raw(
pool: &sqlx::MySqlPool,
query: &str,
page: i64,
page_size: i64,
off: i64,
) -> Result<QueryResult, String> {
let rows = sqlx::query(query)
.fetch_all(pool)
.await
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))?;
let columns: Vec<ColumnInfo> = match rows.first() {
Some(first) => first
.columns()
.iter()
.map(|c| ColumnInfo {
name: c.name().to_string(),
data_type: c.type_info().to_string(),
is_nullable: true,
is_pk: false,
is_fk: false,
fk_ref: None,
default_value: None,
editable: true,
is_generated: false,
})
.collect(),
None => Vec::new(),
};
let all_rows: Vec<Vec<serde_json::Value>> = rows
.iter()
.map(|r| (0..r.len()).map(|i| mysql_cell_to_json(r, i)).collect())
.collect();
let total_rows = all_rows.len() as i64;
let uoff = off as usize;
let ulimit = page_size as usize;
let sliced: Vec<Vec<serde_json::Value>> = if uoff < all_rows.len() {
all_rows.into_iter().skip(uoff).take(ulimit).collect()
} else {
Vec::new()
};
Ok(QueryResult {
columns,
rows: sliced,
total_rows,
page,
page_size,
execution_time_ms: None,
})
}
// ---------------------------------------------------------------------------
// Query history commands
// ---------------------------------------------------------------------------
@@ -959,4 +1119,22 @@ mod tests {
_ => panic!("Expected Sqlite handle"),
}
}
// ------------------------------------------------------------------
// MySQL wrapper SQL shapes
// ------------------------------------------------------------------
#[test]
fn mysql_wrapped_query_shape_has_limit_offset_placeholders() {
// The wrapped-data SQL must use MySQL `?` placeholders (not $1/$2).
let q = mysql_wrap_data("SELECT * FROM t");
assert!(q.contains("LIMIT ? OFFSET ?"));
assert!(q.contains("AS _gridline_data"));
}
#[test]
fn mysql_wrapped_count_shape_uses_subquery_alias() {
let q = mysql_wrap_count("SELECT * FROM t");
assert_eq!(q, "SELECT COUNT(*) FROM (SELECT * FROM t) AS _gridline_cnt");
}
}
+1
View File
@@ -338,6 +338,7 @@ pub async fn get_schema_graph(
})
}
Some(DbHandle::Sqlite(conn)) => build_sqlite_schema_graph(conn, &schema),
Some(DbHandle::MySql(_)) => Err("Schema visualizer not supported for MySQL".to_string()),
None => Err("Connection not found".into()),
}
}
+1
View File
@@ -1,4 +1,5 @@
pub mod introspection;
pub mod mysql;
pub mod pool;
pub mod tls;
+326
View File
@@ -0,0 +1,326 @@
//! Pure MySQL SQL builders for the DB viewer. Identifiers are backtick-quoted
//! (never string-concatenated); values are bound via `?` placeholders at the
//! call site. Mirrors the PG builders in `commands/db_viewer.rs` but with
//! MySQL quoting and `LIMIT 1` on single-row UPDATE/DELETE.
use crate::models::db_viewer::{FilterRule, SortRule};
use sqlx::Row;
/// `SHOW DATABASES` — the browsing branches filter system DBs client-side
/// (see [`MYSQL_SYSTEM_DBS`]).
pub fn mysql_databases_query() -> String {
"SHOW DATABASES".to_string()
}
/// System databases hidden from the DB viewer's database/schema selector.
pub const MYSQL_SYSTEM_DBS: [&str; 4] = ["information_schema", "mysql", "performance_schema", "sys"];
/// Quote a MySQL identifier with backticks, doubling any embedded backticks.
pub fn mysql_quote_ident(name: &str) -> String {
format!("`{}`", name.replace('`', "``"))
}
/// Decode a MySQL row cell as a String. `information_schema` / `SHOW`
/// metadata columns can surface as VARBINARY (bytes) depending on the
/// connection charset, so fall back from String to a UTF-8 lossy decode.
pub fn mysql_row_string(row: &sqlx::mysql::MySqlRow, i: usize) -> String {
if let Ok(s) = row.try_get::<String, _>(i) {
return s;
}
if let Ok(b) = row.try_get::<Vec<u8>, _>(i) {
return String::from_utf8_lossy(&b).into_owned();
}
String::new()
}
/// information_schema.columns query for a table — returns column metadata in
/// the column order the grid expects (name, data_type, is_nullable, column_key,
/// default, extra). Caller maps these into `ColumnInfo`.
pub fn mysql_columns_query(schema: &str, table: &str) -> String {
format!(
"SELECT column_name, data_type, is_nullable, column_key, column_default, extra \
FROM information_schema.columns \
WHERE table_schema = '{}' AND table_name = '{}' \
ORDER BY ordinal_position",
schema.replace('\'', "''"),
table.replace('\'', "''")
)
}
/// Build a `SELECT ... FROM \`schema\`.\`table\` [WHERE ...] [ORDER BY ...] LIMIT ? OFFSET ?`.
/// `filters` produce `?` placeholders (values bound by the caller); `sorts`
/// are quoted identifiers. `default_sort` is used when `sorts` is empty.
pub fn mysql_select_data_query(
schema: &str,
table: &str,
filters: &[FilterRule],
sorts: &[SortRule],
default_sort: &str,
) -> String {
let mut where_parts: Vec<String> = Vec::new();
for f in filters {
let col = mysql_quote_ident(&f.column);
let op = match f.operator.as_str() {
"eq" => format!("{} = ?", col),
"neq" => format!("{} <> ?", col),
"contains" => format!("{} LIKE CONCAT('%', ?, '%')", col),
"starts" => format!("{} LIKE CONCAT(?, '%')", col),
"ends" => format!("{} LIKE CONCAT('%', ?)", col),
"gt" => format!("{} > ?", col),
"lt" => format!("{} < ?", col),
"null" => format!("{} IS NULL", col),
"notnull" => format!("{} IS NOT NULL", col),
_ => format!("{} = ?", col),
};
where_parts.push(op);
}
let where_clause = if where_parts.is_empty() {
String::new()
} else {
format!(" WHERE {}", where_parts.join(" AND "))
};
let order_cols: Vec<String> = sorts
.iter()
.map(|s| format!("{} {}", mysql_quote_ident(&s.column), if s.order.eq_ignore_ascii_case("desc") { "DESC" } else { "ASC" }))
.collect();
let order_clause = if order_cols.is_empty() {
if default_sort.is_empty() {
String::new()
} else {
format!(" ORDER BY {}", mysql_quote_ident(default_sort))
}
} else {
format!(" ORDER BY {}", order_cols.join(", "))
};
format!(
"SELECT * FROM {}{}{} LIMIT ? OFFSET ?",
qualified(schema, table),
where_clause,
order_clause
)
}
pub fn mysql_count_query(schema: &str, table: &str) -> String {
format!("SELECT COUNT(*) FROM {}", qualified(schema, table))
}
pub fn mysql_ddl_query(schema: &str, table: &str) -> String {
format!("SHOW CREATE TABLE {}", qualified(schema, table))
}
/// Foreign-key columns for a table (referenced table/column).
pub fn mysql_fk_query(schema: &str, table: &str) -> String {
format!(
"SELECT column_name, referenced_table_schema, referenced_table_name, referenced_column_name \
FROM information_schema.key_column_usage \
WHERE table_schema = '{}' AND table_name = '{}' AND referenced_table_name IS NOT NULL",
schema.replace('\'', "''"),
table.replace('\'', "''")
)
}
/// Choose a default sort column: prefer an `id`-like column, else the first.
pub fn mysql_default_sort(columns: &[String]) -> &str {
columns.iter().find(|c| c.as_str() == "id").map(|s| s.as_str()).unwrap_or_else(|| {
columns.first().map(|s| s.as_str()).unwrap_or("")
})
}
// ── Change-SQL builders ──────────────────────────────────────────────
pub fn mysql_build_update_sql(
schema: &str,
table: &str,
primary_key: &[(String, serde_json::Value)],
new_data: &[(String, serde_json::Value)],
) -> Result<(String, Vec<serde_json::Value>), String> {
if primary_key.is_empty() {
return Err("cannot update a row without a primary key (MySQL has no ctid)".to_string());
}
let mut params: Vec<serde_json::Value> = Vec::new();
let set_clause: Vec<String> = new_data
.iter()
.map(|(col, val)| { params.push(val.clone()); format!("{} = ?", mysql_quote_ident(col)) })
.collect();
let where_clause: Vec<String> = primary_key
.iter()
.map(|(col, val)| { params.push(val.clone()); format!("{} = ?", mysql_quote_ident(col)) })
.collect();
Ok((
format!(
"UPDATE {} SET {} WHERE {} LIMIT 1",
qualified(schema, table),
set_clause.join(", "),
where_clause.join(" AND ")
),
params,
))
}
pub fn mysql_build_delete_sql(
schema: &str,
table: &str,
primary_key: &[(String, serde_json::Value)],
) -> Result<(String, Vec<serde_json::Value>), String> {
if primary_key.is_empty() {
return Err("cannot delete a row without a primary key (MySQL has no ctid)".to_string());
}
let mut params: Vec<serde_json::Value> = Vec::new();
let where_clause: Vec<String> = primary_key
.iter()
.map(|(col, val)| { params.push(val.clone()); format!("{} = ?", mysql_quote_ident(col)) })
.collect();
Ok((
format!("DELETE FROM {} WHERE {} LIMIT 1", qualified(schema, table), where_clause.join(" AND ")),
params,
))
}
pub fn mysql_build_insert_sql(
schema: &str,
table: &str,
pairs: &[(String, serde_json::Value)],
) -> (String, Vec<serde_json::Value>) {
let cols: Vec<String> = pairs.iter().map(|(c, _)| mysql_quote_ident(c)).collect();
let placeholders: Vec<&str> = pairs.iter().map(|_| "?").collect();
let params: Vec<serde_json::Value> = pairs.iter().map(|(_, v)| v.clone()).collect();
(
format!(
"INSERT INTO {} ({}) VALUES ({})",
qualified(schema, table),
cols.join(", "),
placeholders.join(", ")
),
params,
)
}
pub fn mysql_build_bulk_insert_sql(schema: &str, table: &str, columns: &[String], row_count: usize) -> String {
let cols: Vec<String> = columns.iter().map(|c| mysql_quote_ident(c)).collect();
let one_row = format!("({})", columns.iter().map(|_| "?").collect::<Vec<_>>().join(", "));
let rows = vec![one_row; row_count].join(", ");
format!("INSERT INTO {} ({}) VALUES {}", qualified(schema, table), cols.join(", "), rows)
}
pub fn mysql_build_drop_sql(schema: &str, table: &str) -> String {
format!("DROP TABLE {}", qualified(schema, table))
}
pub fn mysql_build_empty_sql(schema: &str, table: &str) -> String {
format!("DELETE FROM {}", qualified(schema, table))
}
fn qualified(schema: &str, table: &str) -> String {
format!("{}.{}", mysql_quote_ident(schema), mysql_quote_ident(table))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::db_viewer::FilterRule;
#[test]
fn quote_ident_backticks_and_escapes_embedded_backticks() {
assert_eq!(mysql_quote_ident("name"), "`name`");
assert_eq!(mysql_quote_ident("o`d`d"), "`o``d``d`");
assert_eq!(mysql_quote_ident("select"), "`select`");
}
#[test]
fn columns_query_targets_information_schema() {
let q = mysql_columns_query("shop", "orders");
assert!(q.contains("FROM information_schema.columns"));
assert!(q.contains("table_schema = 'shop'"));
assert!(q.contains("table_name = 'orders'"));
assert!(q.contains("ORDER BY ordinal_position"));
}
#[test]
fn select_data_quotes_schema_table_and_applies_limit_offset() {
let q = mysql_select_data_query("shop", "orders", &[], &[], "id");
assert!(q.contains("SELECT * FROM `shop`.`orders`"));
assert!(q.contains("ORDER BY `id`"));
assert!(q.contains("LIMIT ? OFFSET ?"));
}
#[test]
fn select_data_adds_where_for_filters_with_question_placeholders() {
let filters = vec![FilterRule { id: "f1".into(), column: "status".into(), operator: "eq".into(), value: "paid".into() }];
let q = mysql_select_data_query("shop", "orders", &filters, &[], "id");
assert!(q.contains("WHERE `status` = ?"));
assert!(q.contains("LIMIT ? OFFSET ?"));
}
#[test]
fn count_query_uses_quoted_table() {
let q = mysql_count_query("shop", "orders");
assert_eq!(q, "SELECT COUNT(*) FROM `shop`.`orders`");
}
#[test]
fn ddl_query_uses_show_create_table() {
let q = mysql_ddl_query("shop", "orders");
assert_eq!(q, "SHOW CREATE TABLE `shop`.`orders`");
}
#[test]
fn fk_query_targets_key_column_usage() {
let q = mysql_fk_query("shop", "orders");
assert!(q.contains("FROM information_schema.key_column_usage"));
assert!(q.contains("referenced_table_name IS NOT NULL"));
}
#[test]
fn build_update_uses_backticks_question_and_limit_one() {
let pk = vec![("id".to_string(), serde_json::json!(1))];
let data = vec![("status".to_string(), serde_json::json!("paid"))];
let (sql, params) = mysql_build_update_sql("shop", "orders", &pk, &data).unwrap();
assert_eq!(sql, "UPDATE `shop`.`orders` SET `status` = ? WHERE `id` = ? LIMIT 1");
assert_eq!(params, vec![serde_json::json!("paid"), serde_json::json!(1)]);
}
#[test]
fn build_update_rejects_empty_primary_key() {
let r = mysql_build_update_sql("shop", "orders", &[], &[]);
assert!(r.is_err());
}
#[test]
fn build_delete_uses_backticks_question_and_limit_one() {
let pk = vec![("id".to_string(), serde_json::json!(1))];
let (sql, params) = mysql_build_delete_sql("shop", "orders", &pk).unwrap();
assert_eq!(sql, "DELETE FROM `shop`.`orders` WHERE `id` = ? LIMIT 1");
assert_eq!(params, vec![serde_json::json!(1)]);
}
#[test]
fn build_insert_emits_columns_and_question_placeholders() {
let pairs = vec![
("a".to_string(), serde_json::json!(1)),
("b".to_string(), serde_json::json!("x")),
];
let (sql, params) = mysql_build_insert_sql("shop", "orders", &pairs);
assert_eq!(sql, "INSERT INTO `shop`.`orders` (`a`, `b`) VALUES (?, ?)");
assert_eq!(params, vec![serde_json::json!(1), serde_json::json!("x")]);
}
#[test]
fn build_drop_and_empty_table_quoted() {
assert_eq!(mysql_build_drop_sql("shop", "orders"), "DROP TABLE `shop`.`orders`");
assert_eq!(mysql_build_empty_sql("shop", "orders"), "DELETE FROM `shop`.`orders`");
}
#[test]
fn build_bulk_insert_columns_and_placeholders() {
let cols = vec!["a".to_string(), "b".to_string()];
let sql = mysql_build_bulk_insert_sql("shop", "orders", &cols, 2);
assert_eq!(sql, "INSERT INTO `shop`.`orders` (`a`, `b`) VALUES (?, ?), (?, ?)");
}
#[test]
fn default_sort_picks_id_then_first_column() {
assert_eq!(mysql_default_sort(&["updated_at".into(), "id".into()]), "id");
assert_eq!(mysql_default_sort(&["name".into()]), "name");
assert_eq!(mysql_default_sort(&[]), "");
}
}
+29 -3
View File
@@ -82,9 +82,10 @@ impl DbConfig {
/// A handle to an active database connection.
///
/// Supports `Sqlite` (synchronous via `rusqlite`) and
/// `Postgresql` (async via `tokio-postgres`). MySQL and Redis
/// variants will be added in later tasks.
/// Supports `Sqlite` (synchronous via `rusqlite`), `Postgresql` (async via
/// `tokio-postgres`), and `MySql` (async via `sqlx`). Redis has no DB-viewer
/// support. Eviction relies on each variant's `Drop`: `MySqlPool` closes its
/// connections when dropped (mirroring `Postgresql`'s `JoinHandle` abort).
#[derive(Debug)]
pub enum DbHandle {
/// A synchronous SQLite connection via `rusqlite`.
@@ -92,6 +93,8 @@ pub enum DbHandle {
/// An asynchronous PostgreSQL connection via `tokio-postgres`.
/// Stores the client handle and the background connection task.
Postgresql(tokio_postgres::Client, tokio::task::JoinHandle<()>),
/// An asynchronous MySQL connection pool via `sqlx`.
MySql(sqlx::MySqlPool),
}
/// Internal entry stored in the pool manager.
@@ -414,4 +417,27 @@ mod tests {
manager.set_max_pools(1);
assert_eq!(evicted.lock().unwrap().as_slice(), ["a".to_string()]);
}
#[tokio::test]
async fn mysql_handle_can_be_registered_and_evicted() {
// Lazy pool: parses the URL without connecting (no network touch).
let pool = sqlx::mysql::MySqlPoolOptions::new()
.connect_lazy("mysql://__gridline_test__:3306/__none__")
.expect("lazy pool parses url without connecting");
let mut manager = ConnectionPoolManager::new();
manager.set_max_pools(1);
manager.register("mysql-conn", DbHandle::MySql(pool));
assert!(matches!(
manager.get("mysql-conn"),
Some(DbHandle::MySql(_))
));
// Registering a second connection evicts the first (max_pools=1).
let sqlite = rusqlite::Connection::open_in_memory().unwrap();
manager.register("sqlite-conn", DbHandle::Sqlite(sqlite));
assert!(manager.get("mysql-conn").is_none());
assert!(matches!(
manager.get("sqlite-conn"),
Some(DbHandle::Sqlite(_))
));
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Gridline",
"version": "0.6.0",
"version": "0.7.0",
"identifier": "com.adrianbonpin.gridline",
"build": {
"beforeDevCommand": "bun run dev",
@@ -106,4 +106,28 @@ describe("ConnectionCard", () => {
expect(screen.getByText("prod.example.com:5432")).toBeInTheDocument();
expect(screen.getByText("production")).toBeInTheDocument();
});
const manyTags: Tag[] = Array.from({ length: 4 }, (_, i) => ({
id: `t${i + 1}`, name: `tag${i + 1}`, color: "#3b82f6", created_at: "",
}));
const connWith4Tags = { ...conn, tag_ids: manyTags.map((t) => t.id) };
it("renders a scrollable tag row when there are 4+ tags", () => {
const { container } = render(
<ConnectionCard connection={connWith4Tags} tags={manyTags} />,
{ wrapper: Wrapper },
);
const row = container.querySelector('[data-testid="tag-row"]');
expect(row).not.toBeNull();
expect(row?.className).toContain("overflow-x-auto");
});
it("does not scroll the tag row when there are 3 or fewer tags", () => {
const { container } = render(
<ConnectionCard connection={conn} tags={tags} />,
{ wrapper: Wrapper },
);
const row = container.querySelector('[data-testid="tag-row"]');
expect(row?.className).not.toContain("overflow-x-auto");
});
});
@@ -134,9 +134,14 @@ function ConnectionCardBase({
<div className="text-xs text-text-muted mb-2 font-mono truncate">
{hostLabel}
</div>
<div className="flex gap-1 flex-wrap">
<div
data-testid="tag-row"
className={`flex gap-1 ${cardTags.length > 3 ? "overflow-x-auto" : "flex-wrap"} max-h-[28px] whitespace-nowrap`}
>
{cardTags.map((t) => (
<TagBadge key={t.id} tag={t} onToggle={onTagToggle} />
<span key={t.id} className="shrink-0 whitespace-nowrap">
<TagBadge tag={t} onToggle={onTagToggle} />
</span>
))}
</div>
</div>
@@ -1,25 +1,20 @@
import { ChevronLeft } from "lucide-react";
import { Button } from "../ui/Button";
import type { NewConnectionMode } from "../../lib/types";
import type { ReactNode } from "react";
interface ConnectionFormShellProps {
mode: NewConnectionMode;
onBack: () => void;
onTest: () => void;
onSave: () => void;
onToggleMode: () => void;
testLoading?: boolean;
saveLoading?: boolean;
children: ReactNode;
}
export function ConnectionFormShell({
mode,
onBack,
onTest,
onSave,
onToggleMode,
testLoading,
saveLoading,
children,
@@ -54,16 +49,6 @@ export function ConnectionFormShell({
{saveLoading ? "Saving..." : "Save Connection"}
</Button>
</div>
<button
type="button"
onClick={onToggleMode}
className="w-full mt-4 text-sm text-text-muted hover:text-text transition-colors cursor-pointer"
>
{mode === "simple"
? "Configure manually instead →"
: "← Back to connection string"}
</button>
</div>
</div>
);
@@ -0,0 +1,70 @@
import { describe, it, expect, vi } from "vitest";
import { useState } from "react";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ConnectionMetadataRow } from "./ConnectionMetadataRow";
import type { ConnectionFormData } from "./connectionFormData";
import type { Folder, Tag } from "../../lib/types";
const BASE_FORM: ConnectionFormData = {
name: "", environment: null, folder_id: null, tag_ids: [],
connection_string: "", db_type: "postgresql", host: "", port: 5432,
username: null, password: null, database: null, use_keychain: false, ssh_password: null,
};
const folders: Folder[] = [{ id: "f1", name: "Work", parent_id: null, tag_ids: [], created_at: "", updated_at: "" }];
const tags: Tag[] = [{ id: "t1", name: "prod", color: "#f00", created_at: "" }];
describe("ConnectionMetadataRow", () => {
it("renders a Connection Label input and emits name changes", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
function Wrapper() {
const [form, setForm] = useState(BASE_FORM);
return (
<ConnectionMetadataRow
form={form}
folders={folders}
tags={tags}
onChange={(updates) => {
onChange(updates);
setForm((prev) => ({ ...prev, ...updates }));
}}
/>
);
}
render(<Wrapper />);
const label = screen.getByLabelText(/connection label/i);
await user.type(label, "My DB");
expect(onChange).toHaveBeenLastCalledWith({ name: "My DB" });
});
it("toggles the tag picker via + Add Tags and selects a tag", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<ConnectionMetadataRow form={BASE_FORM} folders={folders} tags={tags} onChange={onChange} />);
await user.click(screen.getByRole("button", { name: /add tags/i }));
await user.click(screen.getByRole("button", { name: /prod/i }));
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ tag_ids: ["t1"] }));
});
it("changes the environment via Set Env", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<ConnectionMetadataRow form={BASE_FORM} folders={folders} tags={tags} onChange={onChange} />);
await user.click(screen.getByRole("button", { name: /set env/i }));
const envSection = screen.getByTestId("environment-section");
await user.click(within(envSection).getByRole("button"));
await user.click(within(envSection).getByRole("button", { name: "Production" }));
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ environment: "production" }));
});
it("changes the folder via the folder select", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<ConnectionMetadataRow form={BASE_FORM} folders={folders} tags={tags} onChange={onChange} />);
const folderSection = screen.getByTestId("folder-section");
await user.click(within(folderSection).getByRole("button"));
await user.click(within(folderSection).getByRole("button", { name: "Work" }));
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ folder_id: "f1" }));
});
});
@@ -0,0 +1,81 @@
import { useState } from "react";
import { Input } from "../ui/Input";
import { EnvironmentSelect } from "./EnvironmentSelect";
import { FolderSelect } from "./FolderSelect";
import { SearchableTagPicker } from "../tags/SearchableTagPicker";
import { Plus, Layers } from "lucide-react";
import type { ConnectionFormData } from "./connectionFormData";
import type { Folder, Tag } from "../../lib/types";
interface ConnectionMetadataRowProps {
form: ConnectionFormData;
folders: Folder[];
tags: Tag[];
onChange: (updates: Partial<ConnectionFormData>) => void;
}
export function ConnectionMetadataRow({ form, folders, tags, onChange }: ConnectionMetadataRowProps) {
const [openPanel, setOpenPanel] = useState<"tags" | "env" | null>(null);
const toggle = (panel: "tags" | "env") =>
setOpenPanel((cur) => (cur === panel ? null : panel));
return (
<div className="space-y-3">
<div>
<label className="block text-sm text-text mb-1.5">Connection Label</label>
<Input
value={form.name}
onChange={(value) => onChange({ name: value })}
placeholder="My Production Database"
aria-label="Connection Label"
/>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => toggle("tags")}
aria-label="Add Tags"
className={`flex items-center gap-1 px-3 py-1.5 rounded-lg border text-xs cursor-pointer transition-colors ${
openPanel === "tags" ? "border-accent text-text" : "border-border text-text-muted hover:text-text"
}`}
>
<Plus size={12} /> Add Tags
</button>
<button
type="button"
onClick={() => toggle("env")}
aria-label="Set Env"
className={`flex items-center gap-1 px-3 py-1.5 rounded-lg border text-xs cursor-pointer transition-colors ${
openPanel === "env" ? "border-accent text-text" : "border-border text-text-muted hover:text-text"
}`}
>
<Layers size={12} /> Set Env
</button>
</div>
{openPanel === "tags" && (
<SearchableTagPicker
tags={tags}
selectedTagIds={form.tag_ids ?? []}
onToggle={(tagId) => {
const current = form.tag_ids ?? [];
const next = current.includes(tagId) ? current.filter((id) => id !== tagId) : [...current, tagId];
onChange({ tag_ids: next });
}}
/>
)}
{openPanel === "env" && (
<div data-testid="environment-section">
<label className="block text-sm text-text mb-1.5">Environment</label>
<EnvironmentSelect value={form.environment} onChange={(value) => onChange({ environment: value })} />
</div>
)}
<div data-testid="folder-section">
<label className="block text-sm text-text mb-1.5">Folder</label>
<FolderSelect folders={folders} value={form.folder_id ?? null} onChange={(value) => onChange({ folder_id: value })} />
</div>
</div>
);
}
@@ -26,6 +26,8 @@ const BASE_FORM: ConnectionFormData = {
function StatefulForm(
props: Omit<DetailedConnectionFormProps, "form" | "onChange"> & {
onChange?: (updates: Partial<ConnectionFormData>) => void;
folders?: unknown;
tags?: unknown;
},
) {
const [form, setForm] = useState<ConnectionFormData>(BASE_FORM);
@@ -52,4 +54,24 @@ describe("DetailedConnectionForm", () => {
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ host: "localhost" }));
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ port: 5432 }));
});
it("renders only General and SSH / SSL tabs (no Tags & Env)", () => {
render(<StatefulForm folders={[]} tags={[]} onChange={vi.fn()} />);
expect(screen.getByRole("button", { name: /^general$/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /ssh \/ ssl/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /tags & env/i })).not.toBeInTheDocument();
});
it("renders the metadata row (Connection Label) above the tabs", () => {
render(<StatefulForm folders={[]} tags={[]} onChange={() => {}} />);
expect(screen.getByLabelText(/connection label/i)).toBeInTheDocument();
});
it("updates host via the General tab", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<StatefulForm folders={[]} tags={[]} onChange={onChange} />);
await user.type(screen.getByLabelText(/host/i), "localhost");
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ host: "localhost" }));
});
});
@@ -1,56 +1,35 @@
import { useState } from "react";
import { GeneralTab } from "./GeneralTab";
import { SshSslTab } from "./SshSslTab";
import { TagsEnvTab } from "./TagsEnvTab";
import { ConnectionMetadataRow } from "./ConnectionMetadataRow";
import { useConnectionStore } from "../../stores/connectionStore";
import type { ConnectionFormData } from "./connectionFormData";
export interface DetailedConnectionFormProps {
form: ConnectionFormData;
onChange: (updates: Partial<ConnectionFormData>) => void;
managedPreset?: "supabase" | "neon" | null;
}
export function DetailedConnectionForm({ form, onChange }: DetailedConnectionFormProps) {
const [activeTab, setActiveTab] = useState<"general" | "ssh" | "tags">("general");
export function DetailedConnectionForm({ form, onChange, managedPreset }: DetailedConnectionFormProps) {
const [activeTab, setActiveTab] = useState<"general" | "ssh">("general");
const folders = useConnectionStore((s) => s.folders);
const tags = useConnectionStore((s) => s.tags);
return (
<div className="space-y-4">
<ConnectionMetadataRow form={form} folders={folders ?? []} tags={tags ?? []} onChange={onChange} />
<div>
<div className="flex gap-6 border-b border-border mb-4">
<button
type="button"
onClick={() => setActiveTab("general")}
className={`pb-2 text-sm cursor-pointer transition-colors ${
activeTab === "general" ? "text-text border-b-2 border-text" : "text-text-muted hover:text-text"
}`}
>
General
</button>
<button
type="button"
onClick={() => setActiveTab("ssh")}
className={`pb-2 text-sm cursor-pointer transition-colors ${
activeTab === "ssh" ? "text-text border-b-2 border-text" : "text-text-muted hover:text-text"
}`}
>
SSH / SSL
</button>
<button
type="button"
onClick={() => setActiveTab("tags")}
className={`pb-2 text-sm cursor-pointer transition-colors ${
activeTab === "tags" ? "text-text border-b-2 border-text" : "text-text-muted hover:text-text"
}`}
>
Tags & Env
</button>
<button type="button" onClick={() => setActiveTab("general")} className={`pb-2 text-sm cursor-pointer transition-colors ${activeTab === "general" ? "text-text border-b-2 border-text" : "text-text-muted hover:text-text"}`}>General</button>
<button type="button" onClick={() => setActiveTab("ssh")} className={`pb-2 text-sm cursor-pointer transition-colors ${activeTab === "ssh" ? "text-text border-b-2 border-text" : "text-text-muted hover:text-text"}`}>SSH / SSL</button>
</div>
{activeTab === "general" ? (
<GeneralTab form={form} onChange={onChange} />
) : activeTab === "ssh" ? (
<SshSslTab form={form as unknown as Record<string, unknown>} onChange={onChange as (updates: Record<string, unknown>) => void} />
<GeneralTab form={form} onChange={onChange} managedPreset={managedPreset} />
) : (
<TagsEnvTab form={form} onChange={onChange} />
<SshSslTab form={form as unknown as Record<string, unknown>} onChange={onChange as (u: Record<string, unknown>) => void} />
)}
</div>
</div>
);
}
+34 -29
View File
@@ -4,49 +4,54 @@ import { GeneralTab } from "./GeneralTab";
import type { ConnectionFormData } from "./connectionFormData";
const BASE_FORM: ConnectionFormData = {
name: "",
environment: null,
folder_id: null,
tag_ids: [],
connection_string: "",
db_type: "postgresql",
host: "localhost",
port: 5432,
username: "postgres",
password: "secret",
database: "mydb",
use_keychain: true,
ssh_password: null,
name: "My DB", environment: null, folder_id: null, tag_ids: [],
connection_string: "postgresql://u:p@localhost:5432/db", db_type: "postgresql",
host: "localhost", port: 5432, username: "u", password: "p", database: "db",
use_keychain: false, ssh_password: null,
};
describe("GeneralTab", () => {
it("renders a Name input and passes value to onChange", () => {
const onChange = vi.fn();
render(<GeneralTab form={BASE_FORM} onChange={onChange} />);
const nameInput = screen.getByLabelText("Name");
expect(nameInput).toBeInTheDocument();
expect(nameInput).toHaveValue(BASE_FORM.name);
fireEvent.change(nameInput, { target: { value: "My New Name" } });
expect(onChange).toHaveBeenCalledWith({ name: "My New Name" });
it("renders the Connection URI input (not Name)", () => {
render(<GeneralTab form={BASE_FORM} onChange={() => {}} />);
expect(screen.getByLabelText(/connection uri/i)).toBeInTheDocument();
expect(screen.queryByLabelText("Name")).not.toBeInTheDocument();
});
it("renders host, port, user, password, and database fields", () => {
it("renders the OR divider, host, port, user, password, database, and keychain", () => {
render(<GeneralTab form={BASE_FORM} onChange={() => {}} />);
expect(screen.getByTestId("or-divider")).toBeInTheDocument();
expect(screen.getByLabelText("Host")).toBeInTheDocument();
expect(screen.getByLabelText("Port")).toBeInTheDocument();
expect(screen.getByLabelText("Authentication")).toBeInTheDocument();
expect(screen.getByLabelText("User")).toBeInTheDocument();
expect(screen.getByLabelText("Password")).toBeInTheDocument();
expect(screen.getByLabelText("Database")).toBeInTheDocument();
expect(screen.getByLabelText(/database/i)).toBeInTheDocument();
expect(screen.getByLabelText(/keychain/i)).toBeInTheDocument();
});
it("hides host and port for sqlite but shows database", () => {
render(<GeneralTab form={{ ...BASE_FORM, db_type: "sqlite" }} onChange={() => {}} />);
it("emits host changes", () => {
const onChange = vi.fn();
render(<GeneralTab form={BASE_FORM} onChange={onChange} />);
fireEvent.change(screen.getByLabelText("Host"), { target: { value: "newhost" } });
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ host: "newhost" }));
});
it("shows SqlitePathInput (File Path) and hides Host/Port for sqlite", () => {
render(<GeneralTab form={{ ...BASE_FORM, db_type: "sqlite", host: "/data/x.db", port: null }} onChange={() => {}} />);
expect(screen.getByLabelText(/file path/i)).toBeInTheDocument();
expect(screen.queryByLabelText("Host")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Port")).not.toBeInTheDocument();
expect(screen.getByLabelText("Database")).toBeInTheDocument();
});
it("emits a connection_string change when the URI field is edited", () => {
const onChange = vi.fn();
render(<GeneralTab form={BASE_FORM} onChange={onChange} />);
fireEvent.change(screen.getByLabelText(/connection uri/i), { target: { value: "postgresql://u@h/db" } });
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ connection_string: "postgresql://u@h/db" }));
});
it("shows an SSL hint when a managed-PG preset is active", () => {
render(<GeneralTab form={{ ...BASE_FORM, db_type: "postgresql" }} managedPreset="supabase" onChange={() => {}} />);
expect(screen.getByText(/requires ssl/i)).toBeInTheDocument();
});
});
+37 -54
View File
@@ -1,103 +1,86 @@
import { Input } from "../ui/Input";
import { PasswordInput } from "./PasswordInput";
import { SqlitePathInput } from "./SqlitePathInput";
import { INPUT_ROUNDING } from "../../lib/uiConstants";
import type { ConnectionFormData } from "./connectionFormData";
export interface GeneralTabProps {
form: ConnectionFormData;
onChange: (updates: Partial<ConnectionFormData>) => void;
managedPreset?: "supabase" | "neon" | null;
}
const AUTH_OPTIONS = ["User & Password"];
export function GeneralTab({ form, onChange }: GeneralTabProps) {
export function GeneralTab({ form, onChange, managedPreset }: GeneralTabProps) {
const isSqlite = form.db_type === "sqlite";
return (
<div className="space-y-4">
<div>
<label className="block text-sm text-text mb-1.5">Name</label>
<Input
value={form.name}
onChange={(value) => onChange({ name: value })}
placeholder="My Production Database"
aria-label="Name"
<label className="block text-sm text-text mb-1.5">{isSqlite ? "File Path" : "Connection URI"}</label>
{isSqlite ? (
<SqlitePathInput value={form.host} onChange={(value) => onChange({ host: value })} />
) : (
<input
value={form.connection_string}
onChange={(e) => onChange({ connection_string: e.target.value })}
placeholder="postgresql://user:password@host:5432/database"
aria-label="Connection URI"
className={`w-full ${INPUT_ROUNDING} bg-surface border border-border px-4 py-2 text-sm text-text font-mono placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors`}
/>
)}
{managedPreset && (
<p className="text-xs text-accent-muted mt-1.5">
{managedPreset === "supabase" ? "Supabase" : "NeonDB"} requires SSL enable it under SSH / SSL.
</p>
)}
</div>
{!isSqlite && (
<>
<div className="flex items-center gap-3 my-1" data-testid="or-divider">
<div className="flex-1 h-px bg-border" />
<span className="text-xs text-text-muted">OR</span>
<div className="flex-1 h-px bg-border" />
</div>
<div className="flex gap-3">
<div className="flex-1">
<label className="block text-sm text-text mb-1.5">Host</label>
<Input
value={form.host}
onChange={(value) => onChange({ host: value })}
placeholder="localhost"
aria-label="Host"
/>
<Input value={form.host} onChange={(value) => onChange({ host: value })} placeholder="localhost" aria-label="Host" />
</div>
<div className="w-28">
<label className="block text-sm text-text mb-1.5">Port</label>
<Input
type="number"
value={form.port?.toString() ?? ""}
onChange={(value) => onChange({ port: value === "" ? null : Number(value) })}
placeholder="5432"
aria-label="Port"
/>
<Input type="number" value={form.port?.toString() ?? ""} onChange={(value) => onChange({ port: value === "" ? null : Number(value) })} placeholder="5432" aria-label="Port" />
</div>
</div>
)}
<div>
<label className="block text-sm text-text mb-1.5">Authentication</label>
<select
value={AUTH_OPTIONS[0]}
disabled
className="w-full rounded-full bg-surface border border-border px-4 py-2 text-sm text-text opacity-70 cursor-not-allowed"
aria-label="Authentication"
className={`w-full ${INPUT_ROUNDING} bg-surface border border-border px-4 py-2 text-sm text-text opacity-70 cursor-not-allowed`}
>
{AUTH_OPTIONS.map((opt) => (
<option key={opt}>{opt}</option>
))}
{AUTH_OPTIONS.map((opt) => <option key={opt}>{opt}</option>)}
</select>
</div>
<div>
<label className="block text-sm text-text mb-1.5">User</label>
<Input
value={form.username ?? ""}
onChange={(value) => onChange({ username: value || null })}
placeholder="postgres"
aria-label="User"
/>
<Input value={form.username ?? ""} onChange={(value) => onChange({ username: value || null })} placeholder="postgres" aria-label="User" />
</div>
<div>
<label className="block text-sm text-text mb-1.5">Password</label>
<PasswordInput
value={form.password ?? ""}
onChange={(value) => onChange({ password: value || null })}
placeholder="••••••••"
aria-label="Password"
/>
<PasswordInput value={form.password ?? ""} onChange={(value) => onChange({ password: value || null })} placeholder="••••••••" aria-label="Password" />
</div>
<div>
<label className="block text-sm text-text mb-1.5">Database (optional)</label>
<Input
value={form.database ?? ""}
onChange={(value) => onChange({ database: value || null })}
placeholder="database"
aria-label="Database"
/>
<Input value={form.database ?? ""} onChange={(value) => onChange({ database: value || null })} placeholder="database" aria-label="Database" />
</div>
</>
)}
<label className="flex items-center gap-2 text-sm text-text cursor-pointer">
<input
type="checkbox"
checked={form.use_keychain}
onChange={(e) => onChange({ use_keychain: e.target.checked })}
className="rounded border-border bg-surface text-accent focus:ring-accent"
/>
<input type="checkbox" checked={form.use_keychain} onChange={(e) => onChange({ use_keychain: e.target.checked })} aria-label="Enable keychain" className="rounded border-border bg-surface text-accent focus:ring-accent" />
Enable keychain
</label>
</div>
@@ -4,7 +4,8 @@ import userEvent from "@testing-library/user-event";
import { NewConnectionScreen } from "./NewConnectionScreen";
import { useSettingsStore } from "../../stores/settingsStore";
const { createConnection, notify, testConnection } = vi.hoisted(() => ({
const { createConnection, notify, testConnection } = vi.hoisted(() =>
({
createConnection: vi.fn().mockResolvedValue({}),
notify: vi.fn(),
testConnection: vi.fn().mockResolvedValue({ ok: true }),
@@ -32,148 +33,88 @@ describe("NewConnectionScreen", () => {
useSettingsStore.setState({ settings: null, loading: false, error: null });
});
it("prefills the port from the default_ports setting", async () => {
const user = userEvent.setup();
useSettingsStore.setState({
settings: {
confirm_before_delete: true,
default_folder_id: null,
theme: "dark",
font_size: "medium",
default_ports: { postgresql: 6543, mysql: 3306, sqlite: null, redis: 6379 },
tag_order: null,
table_refresh_rate: 30,
table_page_size: 50,
shortcuts: {},
accent_color: "#2563EB",
editor_font_size: 13,
editor_font_family: "Space Mono",
editor_word_wrap: "off",
editor_minimap: false,
editor_tab_size: 4,
},
});
render(<NewConnectionScreen folders={[]} tags={[]} />);
await user.click(screen.getByText(/configure manually instead/i));
expect(screen.getByLabelText("Port")).toHaveValue(6543);
it("entry stage: renders Connection URI input and provider grid, not the full form", () => {
render(<NewConnectionScreen />);
expect(screen.getByLabelText(/connection uri/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /PostgreSQL/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /MySQL/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Supabase/i })).toBeInTheDocument();
expect(screen.queryByLabelText(/connection label/i)).not.toBeInTheDocument();
});
it("falls back to 5432 when no default port is configured", async () => {
it("paste a recognized URL reveals the form and fills fields", async () => {
const user = userEvent.setup();
render(<NewConnectionScreen folders={[]} tags={[]} />);
await user.click(screen.getByText(/configure manually instead/i));
expect(screen.getByLabelText("Port")).toHaveValue(5432);
});
it("switches to detailed mode and back", async () => {
const user = userEvent.setup();
render(<NewConnectionScreen folders={[]} tags={[]} />);
await user.click(screen.getByText(/configure manually instead/i));
expect(screen.getByText(/general/i)).toBeInTheDocument();
await user.click(screen.getByText(/back to connection string/i));
expect(screen.getByLabelText(/connection string/i)).toBeInTheDocument();
});
it("parses prefilled connection string and populates fields", async () => {
const user = userEvent.setup();
render(
<NewConnectionScreen
prefilledConnectionString="postgresql://u:p@localhost:5432/db"
folders={[]}
tags={[]}
/>,
);
expect(screen.getByLabelText(/connection string/i)).toHaveValue(
"postgresql://u:p@localhost:5432/db",
);
await user.click(screen.getByText(/configure manually instead/i));
render(<NewConnectionScreen />);
await user.type(screen.getByLabelText(/connection uri/i), "postgresql://u:p@localhost:5432/db");
expect(screen.getByLabelText(/connection label/i)).toBeInTheDocument();
expect(screen.getByLabelText("Host")).toHaveValue("localhost");
expect(screen.getByLabelText("Port")).toHaveValue(5432);
expect(screen.getByLabelText("User")).toHaveValue("u");
expect(screen.getByLabelText("Database")).toHaveValue("db");
});
it("shows validation error and does not call createConnection when saving empty form", async () => {
it("clicking a provider tab reveals the form with that db_type", async () => {
const user = userEvent.setup();
render(<NewConnectionScreen folders={[]} tags={[]} />);
await user.click(screen.getByText("Save Connection"));
render(<NewConnectionScreen />);
await user.click(screen.getByRole("button", { name: /MySQL/i }));
expect(screen.getByLabelText(/connection label/i)).toBeInTheDocument();
expect(screen.getByLabelText("Port")).toHaveValue(3306);
});
it("SQLite tab swaps the URI input for a File Path input", async () => {
const user = userEvent.setup();
render(<NewConnectionScreen />);
await user.click(screen.getByRole("button", { name: /SQLite/i }));
expect(screen.getByLabelText(/file path/i)).toBeInTheDocument();
expect(screen.queryByLabelText(/connection uri/i)).not.toBeInTheDocument();
});
it("shows validation error when saving an empty configured form", async () => {
const user = userEvent.setup();
render(<NewConnectionScreen />);
await user.click(screen.getByRole("button", { name: /PostgreSQL/i }));
await user.click(screen.getByText("Save Connection"));
expect(notify).toHaveBeenCalledWith("name is required", "error");
expect(createConnection).not.toHaveBeenCalled();
});
it("saves a connection and invokes onSaved when required fields are filled", async () => {
it("saves a connection with label + parsed URL", async () => {
const user = userEvent.setup();
const onSaved = vi.fn();
render(<NewConnectionScreen folders={[]} tags={[]} onSaved={onSaved} />);
await user.type(screen.getByLabelText("Connection Label"), "Local DB");
await user.type(
screen.getByLabelText("Connection String"),
"postgresql://u:p@localhost:5432/db",
);
render(<NewConnectionScreen onSaved={onSaved} />);
await user.type(screen.getByLabelText(/connection uri/i), "postgresql://u:p@localhost:5432/db");
await user.type(screen.getByLabelText(/connection label/i), "Local DB");
await user.click(screen.getByText("Save Connection"));
await waitFor(() => expect(createConnection).toHaveBeenCalledTimes(1));
expect(createConnection).toHaveBeenCalledWith(
expect.objectContaining({
name: "Local DB",
db_type: "postgresql",
host: "localhost",
port: 5432,
username: "u",
password: "p",
database: "db",
connection_string: "postgresql://u:p@localhost:5432/db",
folder_id: null,
tag_ids: [],
environment: null,
use_keychain: false,
}),
);
expect(createConnection).toHaveBeenCalledWith(expect.objectContaining({
name: "Local DB", db_type: "postgresql", host: "localhost", port: 5432,
username: "u", password: "p", database: "db",
}));
expect(notify).toHaveBeenCalledWith("Connection saved", "success");
expect(onSaved).toHaveBeenCalled();
});
it("calls testConnection when Test Connection is clicked", async () => {
const user = userEvent.setup();
render(<NewConnectionScreen folders={[]} tags={[]} />);
await user.type(screen.getByLabelText("Connection Label"), "Local DB");
await user.type(
screen.getByLabelText("Connection String"),
"postgresql://u:p@localhost:5432/db",
);
render(<NewConnectionScreen />);
await user.type(screen.getByLabelText(/connection uri/i), "postgresql://u:p@localhost:5432/db");
await user.type(screen.getByLabelText(/connection label/i), "Local DB");
await user.click(screen.getByText("Test Connection"));
await waitFor(() => expect(testConnection).toHaveBeenCalledTimes(1));
expect(testConnection).toHaveBeenCalledWith(
expect.objectContaining({
name: "Local DB",
db_type: "postgresql",
host: "localhost",
port: 5432,
username: "u",
password: "p",
database: "db",
}),
);
expect(notify).toHaveBeenCalledWith("Connection successful", "success");
});
it("invokes onCancel when Back is clicked", async () => {
const user = userEvent.setup();
const onCancel = vi.fn();
render(<NewConnectionScreen folders={[]} tags={[]} onCancel={onCancel} />);
render(<NewConnectionScreen onCancel={onCancel} />);
await user.click(screen.getByRole("button", { name: "Back" }));
expect(onCancel).toHaveBeenCalled();
});
it("prefills from prefilledConnectionString and reveals the form", async () => {
render(<NewConnectionScreen prefilledConnectionString="postgresql://u:p@localhost:5432/db" />);
expect(screen.getByLabelText(/connection uri/i)).toHaveValue("postgresql://u:p@localhost:5432/db");
expect(screen.getByLabelText(/connection label/i)).toBeInTheDocument();
expect(screen.getByLabelText("Host")).toHaveValue("localhost");
});
});
@@ -3,28 +3,37 @@ import { useConnectionStore } from "../../stores/connectionStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { useSettingsStore } from "../../stores/settingsStore";
import { ConnectionFormShell } from "./ConnectionFormShell";
import { SimpleConnectionForm } from "./SimpleConnectionForm";
import { DetailedConnectionForm } from "./DetailedConnectionForm";
import { parseConnectionString } from "../../lib/connectionString";
import { ProviderTabsGrid } from "./ProviderTabsGrid";
import { ProviderSetupGuide } from "./ProviderSetupGuide";
import {
parseConnectionString,
detectProviderFromHost,
} from "../../lib/connectionString";
import { getProviderById, type ProviderId } from "../../lib/providers";
import { validateConnectionInput } from "../../lib/utils";
import { testConnection } from "../../lib/commands";
import type {
Folder,
Tag,
NewConnectionMode,
ConnectionInput,
} from "../../lib/types";
import { INPUT_ROUNDING } from "../../lib/uiConstants";
import type { ConnectionInput, Folder, Tag } from "../../lib/types";
import type { ConnectionFormData } from "./connectionFormData";
interface NewConnectionScreenProps {
defaultFolderId?: string | null;
prefilledConnectionString?: string;
folders: Folder[];
tags: Tag[];
folders?: Folder[];
tags?: Tag[];
onSaved?: () => void;
onCancel?: () => void;
}
type Stage = "entry" | "configured";
const FALLBACK_PORTS: Record<string, number> = {
postgresql: 5432,
mysql: 3306,
redis: 6379,
};
function createEmptyForm(
defaultFolderId: string | null = null,
defaultPorts?: Record<string, number | null>,
@@ -48,19 +57,24 @@ function createEmptyForm(
function getDefaultPort(dbType: string): number {
return (
useSettingsStore.getState().settings?.default_ports?.[dbType] ?? 5432
useSettingsStore.getState().settings?.default_ports?.[dbType] ??
FALLBACK_PORTS[dbType] ??
5432
);
}
export function NewConnectionScreen({
defaultFolderId = null,
prefilledConnectionString = "",
folders,
tags,
folders: _folders,
tags: _tags,
onSaved,
onCancel,
}: NewConnectionScreenProps) {
const [mode, setMode] = useState<NewConnectionMode>("simple");
const [stage, setStage] = useState<Stage>("entry");
const [managedPreset, setManagedPreset] = useState<
"supabase" | "neon" | null
>(null);
const [form, setForm] = useState<ConnectionFormData>(() =>
createEmptyForm(
defaultFolderId,
@@ -72,11 +86,23 @@ export function NewConnectionScreen({
const createConnection = useConnectionStore((s) => s.createConnection);
const notify = useNotificationStore((s) => s.notify);
const revealConfigured = useCallback(
(updates: Partial<ConnectionFormData>) => {
setForm((prev) => ({ ...prev, ...updates }));
setStage("configured");
},
[],
);
const handleConnectionStringChange = useCallback((value: string) => {
setForm((prev) => {
const parsed = parseConnectionString(value);
if (!parsed) return { ...prev, connection_string: value };
return {
if (parsed) {
const provider =
parsed.db_type === "postgresql"
? detectProviderFromHost(parsed.host)
: null;
setManagedPreset(provider);
setForm((prev) => ({
...prev,
connection_string: value,
db_type: parsed.db_type,
@@ -85,19 +111,82 @@ export function NewConnectionScreen({
username: parsed.username,
password: parsed.password,
database: parsed.database,
};
});
}));
} else {
setManagedPreset(null);
setForm((prev) => ({ ...prev, connection_string: value }));
}
}, []);
useEffect(() => {
if (
stage === "entry" &&
form.connection_string &&
parseConnectionString(form.connection_string)
) {
setStage("configured");
}
}, [form.connection_string, stage]);
useEffect(() => {
if (prefilledConnectionString) {
handleConnectionStringChange(prefilledConnectionString);
}
}, [prefilledConnectionString, handleConnectionStringChange]);
const updateForm = useCallback((updates: Partial<ConnectionFormData>) => {
setForm((prev) => ({ ...prev, ...updates }));
}, []);
const handleSelectProvider = useCallback(
(id: ProviderId) => {
const provider = getProviderById(id)!;
setManagedPreset(
provider.isManagedPreset ? (id as "supabase" | "neon") : null,
);
revealConfigured({
db_type: provider.dbType,
port:
provider.dbType === "sqlite"
? null
: getDefaultPort(provider.dbType),
...(provider.dbType === "sqlite" ? { host: "" } : {}),
});
},
[revealConfigured],
);
const updateForm = useCallback(
(updates: Partial<ConnectionFormData>) => {
setForm((prev) => {
if (
"connection_string" in updates &&
updates.connection_string !== undefined
) {
const value = updates.connection_string;
const parsed = parseConnectionString(value);
if (parsed) {
const provider =
parsed.db_type === "postgresql"
? detectProviderFromHost(parsed.host)
: null;
setManagedPreset(provider);
return {
...prev,
...updates,
db_type: parsed.db_type,
host: parsed.host,
port:
parsed.port ??
getDefaultPort(parsed.db_type),
username: parsed.username,
password: parsed.password,
database: parsed.database,
};
}
return { ...prev, ...updates };
}
return { ...prev, ...updates };
});
},
[],
);
const buildPayload = useCallback((): ConnectionInput => {
return {
@@ -169,43 +258,81 @@ export function NewConnectionScreen({
}
}, [validate, notify, testConnection, buildPayload]);
const onSimpleChange = useCallback(
(updates: Partial<ConnectionFormData>) => {
if (
"connection_string" in updates &&
updates.connection_string !== undefined
) {
handleConnectionStringChange(updates.connection_string);
} else {
updateForm(updates);
}
},
[handleConnectionStringChange, updateForm],
);
const onToggleMode = useCallback(() => {
setMode((m) => (m === "simple" ? "detailed" : "simple"));
}, []);
const isEntry = stage === "entry";
const showEntryUri = isEntry || form.db_type !== "sqlite";
return (
<ConnectionFormShell
mode={mode}
onBack={() => onCancel?.()}
onTest={handleTest}
onSave={handleSave}
onToggleMode={onToggleMode}
testLoading={testLoading}
saveLoading={saveLoading}
>
{mode === "simple" ? (
<SimpleConnectionForm
form={form}
folders={folders}
tags={tags}
onChange={onSimpleChange}
<div>
{isEntry && showEntryUri && (
<label className="block text-sm text-text mb-1.5">
Connection URI
</label>
)}
{isEntry && form.db_type === "sqlite" ? (
<div>
<label className="block text-sm text-text mb-1.5">
File Path
</label>
<input
value={form.host}
onChange={(e) =>
setForm((prev) => ({
...prev,
host: e.target.value,
}))
}
placeholder="/path/to/database.sqlite"
aria-label="File Path"
className={`w-full ${INPUT_ROUNDING} bg-surface border border-border px-4 py-3 text-sm text-text font-mono placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors`}
/>
</div>
) : (
<DetailedConnectionForm form={form} onChange={updateForm} />
<input
value={form.connection_string}
onChange={(e) =>
handleConnectionStringChange(e.target.value)
}
placeholder="postgresql://user:password@host:5432/database"
aria-label={isEntry ? "Connection URI" : undefined}
className={`w-full ${INPUT_ROUNDING} bg-surface border border-border px-4 py-3 text-sm text-text font-mono placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors ${
isEntry ? "" : "sr-only"
}`}
/>
)}
{isEntry && showEntryUri && (
<p className="text-xs text-text-muted mt-1.5">
Paste a connection string to auto-detect, or pick a
provider below.
</p>
)}
</div>
{isEntry ? (
<>
<div className="flex items-center gap-3 my-1">
<div className="flex-1 h-px bg-border" />
<span className="text-xs text-text-muted">OR</span>
<div className="flex-1 h-px bg-border" />
</div>
<ProviderTabsGrid onSelect={handleSelectProvider} />
</>
) : (
<>
{managedPreset && (
<ProviderSetupGuide provider={managedPreset} />
)}
<DetailedConnectionForm
form={form}
onChange={updateForm}
/>
</>
)}
</ConnectionFormShell>
);
+2 -1
View File
@@ -1,6 +1,7 @@
import { useState, forwardRef } from "react";
import { Eye, EyeOff } from "lucide-react";
import type { KeyboardEvent } from "react";
import { INPUT_ROUNDING } from "../../lib/uiConstants";
interface PasswordInputProps {
value?: string;
@@ -18,7 +19,7 @@ export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
<input
ref={ref}
type={visible ? "text" : "password"}
className={`w-full rounded-full bg-surface border border-border px-4 py-2 pr-10 text-sm text-text placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer ${className}`}
className={`w-full ${INPUT_ROUNDING} bg-surface border border-border px-4 py-2 pr-10 text-sm text-text placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer ${className}`}
onChange={(e) => onChange?.(e.target.value)}
onKeyDown={(e) => onKeyDown?.(e)}
{...rest}
@@ -0,0 +1,37 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ProviderSetupGuide } from "./ProviderSetupGuide";
describe("ProviderSetupGuide", () => {
it("renders Supabase SSL note immediately and steps after expanding", async () => {
const user = userEvent.setup();
render(<ProviderSetupGuide provider="supabase" />);
expect(screen.getByText(/SSL is required by Supabase/i)).toBeInTheDocument();
expect(screen.queryByText(/Open the Supabase Dashboard/i)).not.toBeInTheDocument();
const toggle = screen.getByRole("button", { name: /how to connect/i });
await user.click(toggle);
expect(screen.getByText(/Open the Supabase Dashboard/i)).toBeInTheDocument();
});
it("renders Neon SSL note immediately and steps after expanding", async () => {
const user = userEvent.setup();
render(<ProviderSetupGuide provider="neon" />);
expect(screen.getByText(/Neon requires SSL/i)).toBeInTheDocument();
expect(screen.queryByText(/Open the Neon Console/i)).not.toBeInTheDocument();
const toggle = screen.getByRole("button", { name: /how to connect/i });
await user.click(toggle);
expect(screen.getByText(/Open the Neon Console/i)).toBeInTheDocument();
});
it("starts collapsed and expands on toggle", async () => {
const user = userEvent.setup();
render(<ProviderSetupGuide provider="supabase" />);
const toggle = screen.getByRole("button", { name: /how to connect/i });
expect(screen.queryByText(/Open the Supabase Dashboard/i)).not.toBeInTheDocument();
await user.click(toggle);
expect(screen.getByText(/Open the Supabase Dashboard/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,50 @@
import { useState } from "react";
import { ChevronDown } from "lucide-react";
import { SETUP_GUIDES } from "../../lib/providers";
interface ProviderSetupGuideProps {
provider: "supabase" | "neon";
}
const SSL_NOTE: Record<"supabase" | "neon", string> = {
supabase: "SSL is required by Supabase.",
neon: "Neon requires SSL.",
};
export function ProviderSetupGuide({ provider }: ProviderSetupGuideProps) {
const [open, setOpen] = useState(false);
const guide = SETUP_GUIDES[provider];
return (
<div className="mt-2 border border-border rounded-lg bg-surface">
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-label="How to connect"
className="w-full flex items-center justify-between px-3 py-2 text-xs text-text-muted hover:text-text cursor-pointer transition-colors"
>
<span>How to connect</span>
<ChevronDown
size={14}
className={open ? "rotate-180 transition-transform" : "transition-transform"}
aria-hidden="true"
/>
</button>
{guide.sslRequired && (
<p className="px-3 pb-2 text-xs text-accent-muted">{SSL_NOTE[provider]}</p>
)}
{open && (
<ol className="px-3 pb-3 space-y-2 list-decimal list-inside text-xs text-text-muted">
{guide.steps.map((step, i) => (
<li key={i} className="space-y-0.5">
<span className="text-text font-medium">{step.title}</span>
<p className="text-text-muted">{step.detail}</p>
</li>
))}
</ol>
)}
</div>
);
}
@@ -0,0 +1,42 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ProviderTabsGrid } from "./ProviderTabsGrid";
describe("ProviderTabsGrid", () => {
it("renders exactly 6 provider cards in order", () => {
render(<ProviderTabsGrid onSelect={() => {}} />);
expect(screen.getByRole("button", { name: /PostgreSQL/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /MySQL/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /SQLite/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Redis/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Supabase/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /NeonDB/i })).toBeInTheDocument();
});
it("applies the 2-column grid layout class", () => {
const { container } = render(<ProviderTabsGrid onSelect={() => {}} />);
const grid = container.querySelector('[data-testid="provider-grid"]');
expect(grid?.className).toContain("grid-cols-2");
});
it("calls onSelect with the provider id when a card is clicked", async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
render(<ProviderTabsGrid onSelect={onSelect} />);
await user.click(screen.getByRole("button", { name: /MySQL/i }));
expect(onSelect).toHaveBeenCalledWith("mysql");
});
it("highlights the selected provider", () => {
render(<ProviderTabsGrid onSelect={() => {}} selectedId="mysql" />);
const mysqlBtn = screen.getByRole("button", { name: /MySQL/i });
expect(mysqlBtn.className).toContain("border-accent");
});
it("renders setup-guide content on Supabase and NeonDB cards", () => {
render(<ProviderTabsGrid onSelect={() => {}} />);
expect(screen.getByText(/managed postgresql/i)).toBeInTheDocument();
expect(screen.getByText(/serverless postgres/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,40 @@
import { PROVIDER_TABS, SETUP_GUIDES, type ProviderId } from "../../lib/providers";
import { DbIcon, ProviderIcon } from "../../lib/dbIcons";
interface ProviderTabsGridProps {
selectedId?: ProviderId | null;
onSelect: (id: ProviderId) => void;
}
export function ProviderTabsGrid({ selectedId, onSelect }: ProviderTabsGridProps) {
return (
<div data-testid="provider-grid" className="grid grid-cols-2 gap-3">
{PROVIDER_TABS.map((p) => {
const isSelected = selectedId === p.id;
return (
<button
key={p.id}
type="button"
onClick={() => onSelect(p.id)}
aria-label={p.label}
className={`flex flex-col items-center gap-2 p-4 rounded-lg border bg-surface transition-colors cursor-pointer ${
isSelected
? "border-accent text-text"
: "border-border text-text-muted hover:border-accent/50 hover:text-text"
}`}
>
<span className="w-8 h-8 flex items-center justify-center">
{p.isManagedPreset ? <ProviderIcon id={p.id as "supabase" | "neon"} size={24} /> : <DbIcon type={p.dbType} size={24} />}
</span>
<span className="text-sm font-medium">{p.label}</span>
{p.isManagedPreset && (
<span className="text-[10px] text-text-muted text-center leading-tight">
{SETUP_GUIDES[p.id as keyof typeof SETUP_GUIDES].blurb}
</span>
)}
</button>
);
})}
</div>
);
}
@@ -1,82 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { SimpleConnectionForm } from "./SimpleConnectionForm";
import type { ConnectionFormData } from "./connectionFormData";
import type { SimpleConnectionFormProps } from "./SimpleConnectionForm";
const BASE_FORM: ConnectionFormData = {
name: "",
environment: null,
folder_id: null,
tag_ids: [],
connection_string: "",
db_type: "postgresql",
host: "",
port: 5432,
username: null,
password: null,
database: null,
use_keychain: false,
ssh_password: null,
};
function StatefulForm(
props: Omit<SimpleConnectionFormProps, "form" | "onChange"> & {
onChange?: (updates: Partial<ConnectionFormData>) => void;
},
) {
const [form, setForm] = useState<ConnectionFormData>(BASE_FORM);
return (
<SimpleConnectionForm
{...props}
form={form}
onChange={(updates) => {
setForm((prev) => ({ ...prev, ...updates }));
props.onChange?.(updates);
}}
/>
);
}
describe("SimpleConnectionForm", () => {
it("updates the connection string", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<StatefulForm folders={[]} tags={[]} onChange={onChange} />);
const input = screen.getByLabelText(/connection string/i);
await user.type(input, "postgresql://a@b/c");
expect(onChange).toHaveBeenLastCalledWith({
connection_string: "postgresql://a@b/c",
});
expect(input).toHaveValue("postgresql://a@b/c");
});
it("toggles a tag via the SearchableTagPicker", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const tags = [
{
id: "tag-1",
name: "Work",
color: "#ff0000",
created_at: "2024-01-01T00:00:00Z",
},
{
id: "tag-2",
name: "Personal",
color: "#00ff00",
created_at: "2024-01-01T00:00:00Z",
},
];
render(<StatefulForm folders={[]} tags={tags} onChange={onChange} />);
const workTag = screen.getByText("Work");
await user.click(workTag);
expect(onChange).toHaveBeenLastCalledWith({ tag_ids: ["tag-1"] });
await user.click(workTag);
expect(onChange).toHaveBeenLastCalledWith({ tag_ids: [] });
});
});
@@ -1,84 +0,0 @@
import { Input } from "../ui/Input";
import { EnvironmentSelect } from "./EnvironmentSelect";
import { FolderSelect } from "./FolderSelect";
import { ConnectionStringInput } from "./ConnectionStringInput";
import { SearchableTagPicker } from "../tags/SearchableTagPicker";
import type { ConnectionFormData } from "./connectionFormData";
import type { Folder, Tag } from "../../lib/types";
export interface SimpleConnectionFormProps {
form: ConnectionFormData;
folders: Folder[];
tags: Tag[];
onChange: (updates: Partial<ConnectionFormData>) => void;
}
export function SimpleConnectionForm({
form,
folders,
tags,
onChange,
}: SimpleConnectionFormProps) {
return (
<div className="space-y-4">
<div>
<label className="block text-sm text-text mb-1.5">Label</label>
<Input
value={form.name}
onChange={(value) => onChange({ name: value })}
placeholder="My Production Database"
aria-label="Connection Label"
/>
<p className="text-xs text-text-muted mt-1.5">
A friendly name to identify this connection.
</p>
</div>
<div>
<label className="block text-sm text-text mb-1.5">
Environment
</label>
<EnvironmentSelect
value={form.environment}
onChange={(value) => onChange({ environment: value })}
/>
</div>
<div>
<label className="block text-sm text-text mb-1.5">Folder</label>
<FolderSelect
folders={folders}
value={form.folder_id ?? null}
onChange={(value) => onChange({ folder_id: value })}
/>
</div>
<SearchableTagPicker
tags={tags}
selectedTagIds={form.tag_ids ?? []}
onToggle={(tagId) => {
const current = form.tag_ids ?? [];
const next = current.includes(tagId)
? current.filter((id) => id !== tagId)
: [...current, tagId];
onChange({ tag_ids: next });
}}
/>
<div>
<label className="block text-sm text-text mb-1.5">
Connection String
</label>
<ConnectionStringInput
value={form.connection_string}
onChange={(value) => onChange({ connection_string: value })}
placeholder="postgresql://user:password@host:5432/database"
aria-label="Connection String"
/>
<p className="text-xs text-text-muted mt-1.5">
Paste your connection string to auto-detect database type.
</p>
</div>
</div>
);
}
@@ -0,0 +1,58 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { SqlitePathInput } from "./SqlitePathInput";
function StatefulInput(props: Omit<React.ComponentProps<typeof SqlitePathInput>, "onChange"> & {
onChange?: (value: string) => void;
}) {
const [value, setValue] = useState(props.value ?? "");
return (
<SqlitePathInput
{...props}
value={value}
onChange={(v) => {
setValue(v);
props.onChange?.(v);
}}
/>
);
}
const open = vi.hoisted(() => vi.fn());
vi.mock("@tauri-apps/plugin-dialog", () => ({
open: (...args: unknown[]) => open(...args),
}));
describe("SqlitePathInput", () => {
it("emits typed path changes", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<StatefulInput value="" onChange={onChange} />);
const input = screen.getByLabelText(/file path/i);
await user.type(input, "/Users/me/data.db");
expect(onChange).toHaveBeenLastCalledWith("/Users/me/data.db");
expect(input).toHaveValue("/Users/me/data.db");
});
it("opens the file dialog on Browse and emits the chosen path", async () => {
const user = userEvent.setup();
open.mockResolvedValue("/chosen/path.db");
const onChange = vi.fn();
render(<SqlitePathInput value="" onChange={onChange} />);
await user.click(screen.getByRole("button", { name: /browse/i }));
expect(open).toHaveBeenCalledWith({ multiple: false, directory: false });
expect(onChange).toHaveBeenCalledWith("/chosen/path.db");
});
it("does not emit when the dialog is cancelled", async () => {
const user = userEvent.setup();
open.mockResolvedValue(null);
const onChange = vi.fn();
render(<SqlitePathInput value="/existing.db" onChange={onChange} />);
await user.click(screen.getByRole("button", { name: /browse/i }));
expect(onChange).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,38 @@
import { open } from "@tauri-apps/plugin-dialog";
import { INPUT_ROUNDING } from "../../lib/uiConstants";
interface SqlitePathInputProps {
value: string;
onChange: (value: string) => void;
}
export function SqlitePathInput({ value, onChange }: SqlitePathInputProps) {
const handleBrowse = async () => {
try {
const path = await open({ multiple: false, directory: false });
if (path) onChange(path as string);
} catch {
// dialog unavailable (e.g., web preview) — no-op; user can type the path
}
};
return (
<div className="flex gap-2">
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="/path/to/database.sqlite"
aria-label="File Path"
className={`w-full ${INPUT_ROUNDING} bg-surface border border-border px-4 py-2 text-sm text-text font-mono placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors`}
/>
<button
type="button"
onClick={handleBrowse}
aria-label="Browse"
className={`shrink-0 ${INPUT_ROUNDING} bg-surface-raised border border-border px-3 py-2 text-sm text-text hover:border-accent/50 cursor-pointer transition-colors`}
>
Browse
</button>
</div>
);
}
@@ -8,7 +8,9 @@ import {
} from "./DbViewerScreen";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { useUiStore } from "../../stores/uiStore";
import { useConnectionStore } from "../../stores/connectionStore";
import * as commands from "../../lib/commands";
import type { Connection } from "../../lib/types";
vi.mock("../../hooks/useDbConnection", () => ({
useDbConnection: (_connectionId: string) => ({
@@ -92,6 +94,7 @@ const mockQueryResult = {
describe("DbViewerScreen", () => {
beforeEach(() => {
useDbViewerStore.getState().reset();
useConnectionStore.setState({ connections: [] });
useDbViewerStore.setState({
databases: ["mydb"],
schemas: ["public"],
@@ -596,6 +599,46 @@ describe("DbViewerScreen", () => {
);
});
it("shows the table toolbar while a table tab is still loading its first data", () => {
useDbViewerStore.setState({
tabs: [
{
id: "tab-loading",
schema: "public",
table: "users",
page: 1,
pageSize: 50,
loading: true,
error: null,
data: null, // first fetch still in flight
filterRules: [],
sortRules: [],
hiddenColumns: [],
smartSortApplied: false,
tabType: "table",
},
],
activeTabId: "tab-loading",
});
render(
<DbViewerScreen
connectionId="c1"
onHome={() => {}}
onSettings={() => {}}
/>,
);
// Toolbar must be visible immediately while data is still loading,
// so the user sees the loading state instead of an empty pane.
expect(
screen.getByLabelText(/refresh table/i),
).toBeInTheDocument();
expect(
screen.getByLabelText(/column filters/i),
).toBeInTheDocument();
});
it("disables Insert Row for a materialized-view tab", async () => {
useDbViewerStore.setState({
tables: [
@@ -821,4 +864,88 @@ describe("DbViewerScreen", () => {
const cols = [{ name: "id", data_type: "integer" }];
expect(pickDisplayColumn(cols, "id")).toBe("id");
});
it("shows a Redis unsupported state when a redis connection is active", () => {
useConnectionStore.setState({
connections: [
{
id: "redis-1",
name: "Redis",
db_type: "redis",
host: "localhost",
port: 6379,
username: null,
folder_id: null,
keychain_ref: null,
tag_ids: [],
favorite: false,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
} as Connection,
],
});
render(
<DbViewerScreen
connectionId="redis-1"
onHome={() => {}}
onSettings={() => {}}
/>,
);
expect(
screen.getByText(/redis browsing isn't supported/i),
).toBeInTheDocument();
});
it("guards the Objects view for MySQL (capability false)", () => {
useConnectionStore.setState({
connections: [
{
id: "c1",
name: "Postgres",
db_type: "postgresql",
host: "localhost",
port: 5432,
username: "user",
folder_id: null,
keychain_ref: null,
tag_ids: [],
favorite: false,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
} as Connection,
{
id: "mysql-1",
name: "MySQL",
db_type: "mysql",
host: "localhost",
port: 3306,
username: "user",
folder_id: null,
keychain_ref: null,
tag_ids: [],
favorite: false,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
} as Connection,
],
});
const { rerender } = render(
<DbViewerScreen
connectionId="c1"
onHome={() => {}}
onSettings={() => {}}
/>,
);
fireEvent.click(screen.getByLabelText(/objects/i));
rerender(
<DbViewerScreen
connectionId="mysql-1"
onHome={() => {}}
onSettings={() => {}}
/>,
);
expect(
screen.getByText(/objects is unsupported for mysql/i),
).toBeInTheDocument();
});
});
+20 -4
View File
@@ -1,11 +1,12 @@
import { useCallback, useEffect, useRef, useState, Suspense, lazy } from "react";
import { ChevronDown, ChevronUp, Table2, Terminal, AlertCircle } from "lucide-react";
import { ChevronDown, ChevronUp, Table2, Terminal, AlertCircle, Database } from "lucide-react";
import { format as formatSql } from "sql-formatter";
import { TooltipProvider } from "../ui/Tooltip";
import { DbViewerSidebar } from "./DbViewerSidebar";
import { DbViewerSidebar, NAV_CAPABILITY_KEY } from "./DbViewerSidebar";
import { DbViewerToolbar } from "./DbViewerToolbar";
import { isDestructiveQuery, isSchemaModifyingQuery } from "../../lib/utils";
import { executeQuery } from "../../lib/commands";
import { getCapabilities } from "../../lib/dbCapabilities";
const QueryEditor = lazy(() => import("../editor/QueryEditor").then((m) => ({ default: m.QueryEditor })));
import { QueryToolbar } from "../editor/QueryToolbar";
@@ -173,6 +174,10 @@ export function DbViewerScreen({
const connections = useConnectionStore((s) => s.connections);
const currentConnection =
connections.find((c) => c.id === connectionId) ?? null;
const capabilities = getCapabilities(currentConnection?.db_type ?? "postgresql");
const isRedisUnsupported = (currentConnection?.db_type ?? "postgresql") === "redis" && !capabilities.explorer;
const viewCapabilityKey = NAV_CAPABILITY_KEY[currentView] ?? "explorer";
const viewSupported = capabilities[viewCapabilityKey];
const settings = useSettingsStore((s) => s.settings);
const setDefaultPageSize = useDbViewerStore((s) => s.setDefaultPageSize);
const clearColumnFilter = useDbViewerStore((s) => s.clearColumnFilter);
@@ -1241,7 +1246,7 @@ const onQueriesPanelResizeStart = useCallback(
</span>
</div>
)}
{activeTab?.data && (
{activeTab && (
<TableControls
connectionId={connectionId}
schema={activeSchema}
@@ -1359,6 +1364,7 @@ const onQueriesPanelResizeStart = useCallback(
<DbViewerSidebar
currentView={currentView}
onNavigate={handleNavigate}
capabilities={capabilities}
/>
<div className="flex-1 flex flex-col min-h-0">
{connectionError && connectionError !== dismissedError && (
@@ -1371,7 +1377,17 @@ const onQueriesPanelResizeStart = useCallback(
onDismiss={() => setDismissedError(connectionError)}
/>
)}
{currentView === "db-viewer" ? (
{isRedisUnsupported ? (
<div className="flex-1 flex flex-col items-center justify-center gap-2 text-text-muted">
<Database size={32} />
<span>Redis browsing isn&apos;t supported yet this connection can be tested and used from the Home screen.</span>
</div>
) : !viewSupported ? (
<div className="flex-1 flex flex-col items-center justify-center gap-2 text-text-muted">
<Database size={32} />
<span className="capitalize">{currentView.replace("-", " ")} is unsupported for {currentConnection?.db_type}</span>
</div>
) : currentView === "db-viewer" ? (
<div className="flex flex-1 min-h-0 overflow-hidden">
<div
className="border-r border-border flex flex-col shrink-0"
@@ -3,6 +3,7 @@ import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { DbViewerSidebar } from "./DbViewerSidebar";
import { TooltipProvider } from "../ui/Tooltip";
import { DB_CAPABILITIES } from "../../lib/dbCapabilities";
describe("DbViewerSidebar", () => {
it("renders all navigation icons", () => {
@@ -68,4 +69,56 @@ describe("DbViewerSidebar", () => {
);
expect(screen.getByLabelText("Tools")).toBeInTheDocument();
});
it("shows all 5 tools for PostgreSQL (default capabilities)", () => {
render(
<TooltipProvider>
<DbViewerSidebar currentView="db-viewer" onNavigate={() => {}} />
</TooltipProvider>
);
expect(screen.getByLabelText(/explorer/i)).toBeInTheDocument();
expect(screen.getByLabelText("Queries")).toBeInTheDocument();
expect(screen.getByLabelText(/schema visualizer/i)).toBeInTheDocument();
expect(screen.getByLabelText(/objects/i)).toBeInTheDocument();
expect(screen.getByLabelText(/tools/i)).toBeInTheDocument();
});
it("hides Objects and Tools for SQLite", () => {
render(
<TooltipProvider>
<DbViewerSidebar currentView="db-viewer" onNavigate={() => {}} capabilities={DB_CAPABILITIES.sqlite} />
</TooltipProvider>
);
expect(screen.getByLabelText(/explorer/i)).toBeInTheDocument();
expect(screen.getByLabelText("Queries")).toBeInTheDocument();
expect(screen.getByLabelText(/schema visualizer/i)).toBeInTheDocument();
expect(screen.queryByLabelText(/objects/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/tools/i)).not.toBeInTheDocument();
});
it("hides Objects, Visualizer, and Tools for MySQL", () => {
render(
<TooltipProvider>
<DbViewerSidebar currentView="db-viewer" onNavigate={() => {}} capabilities={DB_CAPABILITIES.mysql} />
</TooltipProvider>
);
expect(screen.getByLabelText(/explorer/i)).toBeInTheDocument();
expect(screen.getByLabelText("Queries")).toBeInTheDocument();
expect(screen.queryByLabelText(/schema visualizer/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/objects/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/tools/i)).not.toBeInTheDocument();
});
it("shows no top nav items for Redis (unsupported browsing)", () => {
render(
<TooltipProvider>
<DbViewerSidebar currentView="db-viewer" onNavigate={() => {}} capabilities={DB_CAPABILITIES.redis} />
</TooltipProvider>
);
expect(screen.queryByLabelText(/explorer/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText("Queries")).not.toBeInTheDocument();
expect(screen.queryByLabelText(/schema visualizer/i)).not.toBeInTheDocument();
expect(screen.getByLabelText(/home/i)).toBeInTheDocument();
expect(screen.getByLabelText(/settings/i)).toBeInTheDocument();
});
});
+16 -1
View File
@@ -8,12 +8,22 @@ import {
Share2,
} from "lucide-react";
import { Tooltip } from "../ui/Tooltip";
import { DB_CAPABILITIES, type DbCapabilities } from "../../lib/dbCapabilities";
export interface DbViewerSidebarProps {
currentView: string;
onNavigate: (view: string) => void;
capabilities?: DbCapabilities;
}
export const NAV_CAPABILITY_KEY: Record<string, keyof DbCapabilities> = {
"db-viewer": "explorer",
queries: "queries",
"schema-visualizer": "visualizer",
objects: "objects",
tools: "tools",
};
interface NavItem {
id: string;
label: string;
@@ -24,6 +34,7 @@ interface NavItem {
export function DbViewerSidebar({
currentView,
onNavigate,
capabilities = DB_CAPABILITIES.postgresql,
}: DbViewerSidebarProps) {
const topItems: NavItem[] = [
{ id: "db-viewer", label: "Explorer", icon: <Database size={16} /> },
@@ -41,6 +52,10 @@ export function DbViewerSidebar({
{ id: "tools", label: "Tools", icon: <DatabaseBackup size={16} /> },
];
const visibleTopItems = topItems.filter(
(item) => capabilities[NAV_CAPABILITY_KEY[item.id]]
);
const bottomItems: NavItem[] = [
{ id: "home", label: "Home", icon: <Home size={16} /> },
{ id: "settings", label: "Settings", icon: <Settings size={16} /> },
@@ -73,7 +88,7 @@ export function DbViewerSidebar({
return (
<div className="w-14 h-full bg-canvas border-r border-border flex flex-col items-center py-3 gap-2 shrink-0">
<div className="flex flex-col gap-2 flex-1">
{topItems.map(renderItem)}
{visibleTopItems.map(renderItem)}
</div>
<div className="flex flex-col gap-2">
{bottomItems.map(renderItem)}
@@ -83,4 +83,29 @@ describe("DbViewerToolbar", () => {
expect(screen.getByLabelText(/refresh/i)).toBeInTheDocument();
expect(screen.getByLabelText(/create table/i)).toBeInTheDocument();
});
it("shows a disabled schema loading indicator while schema tree is loading", () => {
useDbViewerStore.setState({ schemaTreeLoading: true });
render(
<TooltipProvider>
<DbViewerToolbar {...defaultProps} />
</TooltipProvider>,
);
expect(screen.getByLabelText(/select schema/i)).toBeDisabled();
expect(screen.getByText(/Loading/i)).toBeInTheDocument();
});
it("renders schema options when not loading and multiple schemas exist", () => {
useDbViewerStore.setState({ schemaTreeLoading: false });
render(
<TooltipProvider>
<DbViewerToolbar
{...defaultProps}
schemas={["public", "app"]}
currentSchema="public"
/>
</TooltipProvider>,
);
expect(screen.getByText("public")).toBeInTheDocument();
});
});
+14 -11
View File
@@ -43,6 +43,7 @@ export function DbViewerToolbar({
const searchInputRef = useRef<HTMLInputElement>(null);
const searchContainerRef = useRef<HTMLDivElement>(null);
const populate = useDbViewerStore((s) => s.populate);
const schemaTreeLoading = useDbViewerStore((s) => s.schemaTreeLoading);
// Focus input when search opens
useEffect(() => {
@@ -95,7 +96,7 @@ export function DbViewerToolbar({
}, [connectionId, refreshing, populate]);
const hasBelow =
searchOpen || databases.length > 1 || schemas.length > 1;
searchOpen || databases.length > 1 || schemas.length > 1 || schemaTreeLoading;
return (
<div
@@ -193,7 +194,7 @@ export function DbViewerToolbar({
)}
</div>
</div>
{(databases.length > 1 || schemas.length > 1) && (
{(databases.length > 1 || schemas.length > 1 || schemaTreeLoading) && (
<div className="flex items-center gap-2">
{databases.length > 1 && (
<SelectDropdown
@@ -208,20 +209,22 @@ export function DbViewerToolbar({
variant="ghost"
/>
)}
{databases.length > 1 && schemas.length > 1 && (
{databases.length > 1 && (schemas.length > 1 || schemaTreeLoading) && (
<span className="text-border">|</span>
)}
{schemas.length > 1 && (
{(schemas.length > 1 || schemaTreeLoading) && (
<SelectDropdown
value={currentSchema ?? ""}
onChange={setCurrentSchema}
options={schemas.map((s) => ({
value: s,
label: s,
}))}
placeholder="Select schema"
value={schemaTreeLoading ? "" : (currentSchema ?? "")}
onChange={schemaTreeLoading ? () => {} : setCurrentSchema}
options={
schemaTreeLoading
? [{ value: "", label: "Loading…" }]
: schemas.map((s) => ({ value: s, label: s }))
}
placeholder={schemaTreeLoading ? "Loading…" : "Select schema"}
aria-label="Select schema"
variant="ghost"
disabled={schemaTreeLoading}
/>
)}
</div>
@@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { EditConnectionModal } from "./EditConnectionModal";
import type { Connection } from "../../lib/types";
const { updateConnection, loadAll } = vi.hoisted(() => ({
updateConnection: vi.fn().mockResolvedValue({}),
loadAll: vi.fn(),
}));
vi.mock("../../stores/connectionStore", () => ({
useConnectionStore: (sel: (s: any) => any) =>
sel({ updateConnection, loadAll, folders: [], tags: [] }),
}));
vi.mock("../../lib/commands", () => ({
updateConnection: vi.fn(),
testConnection: vi.fn(),
saveConnectionPassword: vi.fn(),
saveConnectionSshPassword: vi.fn(),
saveConnectionSshPassphrase: vi.fn(),
}));
vi.mock("../../stores/notificationStore", () => ({
useNotificationStore: (sel: (s: any) => any) => sel({ notify: vi.fn() }),
}));
const baseConn: Connection = {
id: "c1",
name: "Prod",
db_type: "postgresql",
host: "localhost",
port: 5432,
username: "u",
folder_id: null,
keychain_ref: null,
tag_ids: [],
favorite: false,
created_at: "",
updated_at: "",
database: "db",
};
describe("EditConnectionModal", () => {
beforeEach(() => vi.clearAllMocks());
it("renders the Connection Label field and General/SSH tabs", () => {
render(
<EditConnectionModal
connection={baseConn}
open={true}
onClose={() => {}}
onSaved={() => {}}
/>
);
expect(screen.getByLabelText(/connection label/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /^general$/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /ssh \/ ssl/i })).toBeInTheDocument();
});
it("shows the Supabase SSL hint when editing a Supabase-host connection", () => {
const supa = { ...baseConn, host: "db.abcdefghijklmnopqrst.supabase.co" };
render(
<EditConnectionModal
connection={supa}
open={true}
onClose={() => {}}
onSaved={() => {}}
/>
);
expect(screen.getByText(/requires ssl/i)).toBeInTheDocument();
});
});
@@ -5,6 +5,7 @@ import { DetailedConnectionForm } from "../connections/DetailedConnectionForm";
import { useConnectionStore } from "../../stores/connectionStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { updateConnection, testConnection, saveConnectionPassword, saveConnectionSshPassword, saveConnectionSshPassphrase } from "../../lib/commands";
import { detectProviderFromHost } from "../../lib/connectionString";
import type { Connection, ConnectionInput } from "../../lib/types";
import type { ConnectionFormData } from "../connections/connectionFormData";
@@ -21,6 +22,8 @@ export function EditConnectionModal({
onClose,
onSaved,
}: EditConnectionModalProps) {
const managedPreset = detectProviderFromHost(connection.host);
const [form, setForm] = useState<ConnectionFormData>(() => ({
name: connection.name,
environment: (connection.environment as ConnectionFormData["environment"]) ?? null,
@@ -132,7 +135,7 @@ export function EditConnectionModal({
<AnimatedModal open={open} onClose={onClose}>
<div className="w-full min-w-md max-w-lg max-h-[80vh] overflow-y-auto">
<h3 className="font-heading text-text text-lg mb-4">Edit Connection</h3>
<DetailedConnectionForm form={form} onChange={(updates) => setForm((prev) => ({ ...prev, ...updates }))} />
<DetailedConnectionForm form={form} onChange={(updates) => setForm((prev) => ({ ...prev, ...updates }))} managedPreset={managedPreset} />
<div className="flex justify-end gap-2 mt-4">
<Button variant="ghost" onClick={handleTest} disabled={testing}>
{testing ? "Testing..." : "Test"}
@@ -70,4 +70,18 @@ describe("TableTree", () => {
expect(state.tabs).toHaveLength(1);
expect(state.tabs[0]).toMatchObject({ schema: "public", table: "users" });
});
it("shows Loading when schema tree is loading and no tables are present", () => {
useDbViewerStore.setState({ schemaTreeLoading: true });
render(<TableTree />);
expect(screen.getByText("Loading…")).toBeInTheDocument();
expect(screen.queryByText("No tables")).not.toBeInTheDocument();
});
it("shows No tables when not loading and no tables are present", () => {
useDbViewerStore.setState({ schemaTreeLoading: false });
render(<TableTree />);
expect(screen.getByText("No tables")).toBeInTheDocument();
expect(screen.queryByText("Loading…")).not.toBeInTheDocument();
});
});
+2 -1
View File
@@ -10,6 +10,7 @@ import * as cmd from "../../lib/commands";
export function TableTree({ searchQuery }: { searchQuery?: string }) {
const tables = useDbViewerStore((s) => s.tables);
const currentSchema = useDbViewerStore((s) => s.currentSchema);
const schemaTreeLoading = useDbViewerStore((s) => s.schemaTreeLoading);
const openTab = useDbViewerStore((s) => s.openTab);
const connectionId = useUiStore((s) => s.activeConnectionId);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
@@ -63,7 +64,7 @@ export function TableTree({ searchQuery }: { searchQuery?: string }) {
<div>
{filteredTables.length === 0 && (
<div className="px-3 py-2 text-sm text-text-muted">
No tables
{schemaTreeLoading ? "Loading…" : "No tables"}
</div>
)}
{filteredTables.map((table) => {
+2 -2
View File
@@ -24,7 +24,7 @@ export function SearchableTagPicker({ tags, selectedTagIds, onToggle }: Searchab
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search tags..."
className="w-full rounded-full bg-surface border border-border pl-7 pr-3 py-1.5 text-xs text-text placeholder-text-muted/60 focus:outline-none focus:border-accent transition-colors"
className="w-full rounded-lg bg-surface border border-border pl-7 pr-3 py-1.5 text-xs text-text placeholder-text-muted/60 focus:outline-none focus:border-accent transition-colors"
/>
</div>
<div className="max-h-32 overflow-y-auto space-y-1">
@@ -100,7 +100,7 @@ function InlineTagCreator({ onCreated }: { onCreated: (tagId: string) => void })
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Tag name"
className="w-full rounded-full bg-surface border border-border px-3 py-1.5 text-xs text-text placeholder-text-muted/60 focus:outline-none focus:border-accent transition-colors"
className="w-full rounded-lg bg-surface border border-border px-3 py-1.5 text-xs text-text placeholder-text-muted/60 focus:outline-none focus:border-accent transition-colors"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") handleCreate();
+7
View File
@@ -14,4 +14,11 @@ describe("Input", () => {
await userEvent.type(screen.getByPlaceholderText("x"), "hi");
expect(fn).toHaveBeenLastCalledWith("hi");
});
it("uses the shared rounded (non-pill) radius", () => {
const { container } = render(<Input value="" onChange={() => {}} aria-label="x" />);
const input = container.querySelector("input")!;
expect(input.className).toContain("rounded-lg");
expect(input.className).not.toContain("rounded-full");
});
});
+2 -1
View File
@@ -1,5 +1,6 @@
import { forwardRef } from "react";
import type { KeyboardEvent } from "react";
import { INPUT_ROUNDING } from "../../lib/uiConstants";
interface InputProps {
value?: string;
@@ -17,7 +18,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
return (
<input
ref={ref}
className={`w-full rounded-full bg-surface border border-border px-4 py-2 text-sm text-text placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer ${className}`}
className={`w-full ${INPUT_ROUNDING} bg-surface border border-border px-4 py-2 text-sm text-text placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer ${className}`}
onChange={(e) => onChange?.(e.target.value)}
onKeyDown={(e) => onKeyDown?.(e)}
{...rest}
+25
View File
@@ -30,4 +30,29 @@ describe("SelectDropdown", () => {
await user.click(screen.getByRole("button", { name: /Development/i }));
expect(onChange).toHaveBeenCalledWith("development");
});
it("does not open the menu when disabled", async () => {
const onChange = vi.fn();
const user = userEvent.setup();
render(
<SelectDropdown
value="staging"
onChange={onChange}
options={[
{ value: "production", label: "Production" },
{ value: "staging", label: "Staging" },
]}
placeholder="None"
disabled
/>,
);
const trigger = screen.getByRole("button", { name: /Staging/i });
expect(trigger).toBeDisabled();
await user.click(trigger);
expect(
screen.queryByRole("button", { name: /Production/i }),
).not.toBeInTheDocument();
expect(onChange).not.toHaveBeenCalled();
});
});
+9 -2
View File
@@ -13,6 +13,7 @@ interface SelectDropdownProps {
placeholder?: string;
variant?: "pill" | "ghost";
"aria-label"?: string;
disabled?: boolean;
}
export function SelectDropdown({
@@ -22,12 +23,17 @@ export function SelectDropdown({
placeholder = "Select…",
variant = "pill",
"aria-label": ariaLabel,
disabled = false,
}: SelectDropdownProps) {
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const selectedLabel =
options.find((opt) => opt.value === value)?.label ?? placeholder;
const disabledButtonClass = variant === "ghost"
? " opacity-50 cursor-not-allowed"
: " disabled:opacity-50 disabled:cursor-not-allowed";
useEffect(() => {
if (!open) return;
const handleMouseDown = (e: MouseEvent) => {
@@ -62,9 +68,10 @@ export function SelectDropdown({
<div className="relative" ref={menuRef}>
<button
type="button"
onClick={() => setOpen((o) => !o)}
onClick={() => !disabled && setOpen((o) => !o)}
aria-label={ariaLabel}
className={buttonClass}
disabled={disabled}
className={buttonClass + (disabled ? disabledButtonClass : "")}
>
<span className="truncate">{selectedLabel}</span>
<ChevronDown
+25
View File
@@ -73,6 +73,31 @@ describe("useDbConnection", () => {
mockConnection.ssh_auth_method = null;
});
it("sets schemaTreeLoading around the connect fetch", async () => {
mockCommands.getSchemas.mockImplementation(
() => new Promise((res) => setTimeout(() => res(["public"]), 50)),
);
render(<Harness />);
fireEvent.click(screen.getByText("connect"));
await waitFor(() =>
expect(useDbViewerStore.getState().schemaTreeLoading).toBe(true),
);
await waitFor(() =>
expect(useDbViewerStore.getState().schemaTreeLoading).toBe(false),
);
});
it("clears schemaTreeLoading when connect throws", async () => {
mockCommands.getSchemas.mockRejectedValue(new Error("boom"));
render(<Harness />);
fireEvent.click(screen.getByText("connect"));
await waitFor(() =>
expect(useDbViewerStore.getState().schemaTreeLoading).toBe(false),
);
});
it("connects and smart-selects the public schema when available", async () => {
render(<Harness />);
fireEvent.click(screen.getByText("connect"));
+11 -2
View File
@@ -9,6 +9,7 @@ import type { ConnectionInput, TableInfo } from "../lib/types";
export function useDbConnection(connectionId: string) {
const reset = useDbViewerStore((s) => s.reset);
const populate = useDbViewerStore((s) => s.populate);
const setSchemaTreeLoading = useDbViewerStore((s) => s.setSchemaTreeLoading);
const currentDatabase = useDbViewerStore((s) => s.currentDatabase);
const setCurrentDatabase = useDbViewerStore((s) => s.setCurrentDatabase);
const setCurrentSchema = useDbViewerStore((s) => s.setCurrentSchema);
@@ -74,6 +75,8 @@ export function useDbConnection(connectionId: string) {
: (input.database ?? "postgres");
// Load initial data, smart-selecting the default schema (e.g. `public`)
setSchemaTreeLoading(true);
try {
const databases = await cmd
.getDatabases(connectionId)
.catch(() => [] as string[]);
@@ -96,12 +99,15 @@ export function useDbConnection(connectionId: string) {
setCurrentDatabase(preferred);
}
setCurrentSchema(defaultSchema);
} finally {
setSchemaTreeLoading(false);
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
setConnectionError(msg);
notify(`Failed to connect: ${msg}`, "error");
}
}, [connectionId, populate, setCurrentDatabase, setCurrentSchema, notify]);
}, [connectionId, populate, setCurrentDatabase, setCurrentSchema, setSchemaTreeLoading, notify]);
useEffect(() => {
connect();
@@ -124,6 +130,7 @@ export function useDbConnection(connectionId: string) {
const reconnect = async () => {
const input = { ...inputRef.current!, database: currentDatabase };
try {
setSchemaTreeLoading(true);
await cmd.dbConnect(connectionId, input);
if (cancelled) return;
connectedDbRef.current = currentDatabase;
@@ -143,13 +150,15 @@ export function useDbConnection(connectionId: string) {
// Revert the selection so the dropdown matches the live connection
setCurrentDatabase(connectedDbRef.current);
notify(`Failed to switch database: ${msg}`, "error");
} finally {
if (!cancelled) setSchemaTreeLoading(false);
}
};
reconnect();
return () => {
cancelled = true;
};
}, [currentDatabase, connectionId, populate, setCurrentSchema, notify]);
}, [currentDatabase, connectionId, populate, setCurrentSchema, setSchemaTreeLoading, notify]);
return { connectionError, connect };
}
+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;
}
+39 -1
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
@@ -57,3 +57,41 @@ export const DB_ICONS: Record<DbType, string> = {
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");
});
});
+11
View File
@@ -24,6 +24,7 @@ describe("dbViewerStore", () => {
expect(state.tables).toEqual([]);
expect(state.currentDatabase).toBeNull();
expect(state.currentSchema).toBeNull();
expect(state.schemaTreeLoading).toBe(false);
});
it("openTab adds a new tab", () => {
@@ -232,6 +233,15 @@ describe("dbViewerStore", () => {
expect(useDbViewerStore.getState().changesQueue).toHaveLength(0);
});
it("setSchemaTreeLoading toggles the loading flag", () => {
const store = useDbViewerStore.getState();
expect(useDbViewerStore.getState().schemaTreeLoading).toBe(false);
store.setSchemaTreeLoading(true);
expect(useDbViewerStore.getState().schemaTreeLoading).toBe(true);
store.setSchemaTreeLoading(false);
expect(useDbViewerStore.getState().schemaTreeLoading).toBe(false);
});
it("reset clears all state", () => {
const store = useDbViewerStore.getState();
store.openTab("public", "users");
@@ -249,6 +259,7 @@ describe("dbViewerStore", () => {
expect(state.tables).toEqual([]);
expect(state.currentDatabase).toBeNull();
expect(state.currentSchema).toBeNull();
expect(state.schemaTreeLoading).toBe(false);
});
it("populate sets databases, schemas, tables", () => {
+4
View File
@@ -89,6 +89,7 @@ interface DbViewerState {
databases: string[];
schemas: string[];
tables: TableInfo[];
schemaTreeLoading: boolean;
currentDatabase: string | null;
currentSchema: string | null;
functions: FunctionInfo[] | null;
@@ -97,6 +98,7 @@ interface DbViewerState {
enums: EnumInfo[] | null;
extensions: ExtensionInfo[] | null;
indexes: IndexInfo[] | null;
setSchemaTreeLoading: (loading: boolean) => void;
constraints: ConstraintInfo[] | null;
// Actions
@@ -184,6 +186,7 @@ const initialState = {
extensions: null as ExtensionInfo[] | null,
indexes: null as IndexInfo[] | null,
constraints: null as ConstraintInfo[] | null,
schemaTreeLoading: false,
};
// ─── Store ──────────────────────────────────────────────────────
@@ -437,6 +440,7 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
setExtensions: (extensions) => set({ extensions }),
setIndexes: (indexes) => set({ indexes }),
setConstraints: (constraints) => set({ constraints }),
setSchemaTreeLoading: (loading) => set({ schemaTreeLoading: loading }),
stageCellEdit: (input) => {
// Re-staging the same cell replaces the existing pending entry (keeps the