From 1195d2c3f907d3c9ff28870ff1fa5c7d9740e653 Mon Sep 17 00:00:00 2001 From: "Adrian Alfred C. Bonpin" Date: Tue, 4 Aug 2026 21:13:20 +0800 Subject: [PATCH] v0.7.0: New Connection screen revamp + full MySQL DB viewer (#10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) --- .github/ISSUE_TEMPLATE/bug_report.md | 81 ++ .github/workflows/release.yml | 4 +- AGENTS.md | 27 +- README.md | 94 ++- ROADMAP.md | 145 ++++ package.json | 2 +- public/tauri.svg | 6 - public/vite.svg | 1 - src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 4 +- src-tauri/src/commands/db_viewer.rs | 717 +++++++++++++++++- src-tauri/src/commands/query.rs | 178 +++++ src-tauri/src/commands/schema_graph.rs | 1 + src-tauri/src/db/mod.rs | 1 + src-tauri/src/db/mysql.rs | 326 ++++++++ src-tauri/src/db/pool.rs | 32 +- src-tauri/tauri.conf.json | 2 +- .../connections/ConnectionCard.test.tsx | 24 + src/components/connections/ConnectionCard.tsx | 9 +- .../connections/ConnectionFormShell.tsx | 17 +- .../ConnectionMetadataRow.test.tsx | 70 ++ .../connections/ConnectionMetadataRow.tsx | 81 ++ .../DetailedConnectionForm.test.tsx | 22 + .../connections/DetailedConnectionForm.tsx | 59 +- .../connections/GeneralTab.test.tsx | 63 +- src/components/connections/GeneralTab.tsx | 137 ++-- .../connections/NewConnectionScreen.test.tsx | 169 ++--- .../connections/NewConnectionScreen.tsx | 229 ++++-- src/components/connections/PasswordInput.tsx | 3 +- .../connections/ProviderSetupGuide.test.tsx | 37 + .../connections/ProviderSetupGuide.tsx | 50 ++ .../connections/ProviderTabsGrid.test.tsx | 42 + .../connections/ProviderTabsGrid.tsx | 40 + .../connections/SimpleConnectionForm.test.tsx | 82 -- .../connections/SimpleConnectionForm.tsx | 84 -- .../connections/SqlitePathInput.test.tsx | 58 ++ .../connections/SqlitePathInput.tsx | 38 + .../db-viewer/DbViewerScreen.test.tsx | 127 ++++ src/components/db-viewer/DbViewerScreen.tsx | 24 +- .../db-viewer/DbViewerSidebar.test.tsx | 53 ++ src/components/db-viewer/DbViewerSidebar.tsx | 17 +- .../db-viewer/DbViewerToolbar.test.tsx | 25 + src/components/db-viewer/DbViewerToolbar.tsx | 25 +- .../db-viewer/EditConnectionModal.test.tsx | 71 ++ .../db-viewer/EditConnectionModal.tsx | 5 +- src/components/db-viewer/TableTree.test.tsx | 14 + src/components/db-viewer/TableTree.tsx | 3 +- src/components/tags/SearchableTagPicker.tsx | 4 +- src/components/ui/Input.test.tsx | 7 + src/components/ui/Input.tsx | 3 +- src/components/ui/SelectDropdown.test.tsx | 25 + src/components/ui/SelectDropdown.tsx | 11 +- src/hooks/useDbConnection.test.tsx | 25 + src/hooks/useDbConnection.ts | 55 +- src/lib/connectionString.test.ts | 34 +- src/lib/connectionString.ts | 28 +- src/lib/dbCapabilities.test.ts | 53 ++ src/lib/dbCapabilities.ts | 37 + src/lib/dbIcons.tsx | 42 +- src/lib/docs-coverage.test.ts | 11 +- src/lib/providers.test.ts | 36 + src/lib/providers.ts | 96 +++ src/lib/uiConstants.test.ts | 8 + src/lib/uiConstants.ts | 4 + src/lib/version.test.ts | 4 +- src/stores/dbViewerStore.test.ts | 11 + src/stores/dbViewerStore.ts | 4 + 67 files changed, 3150 insertions(+), 649 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 ROADMAP.md delete mode 100644 public/tauri.svg delete mode 100644 public/vite.svg create mode 100644 src-tauri/src/db/mysql.rs create mode 100644 src/components/connections/ConnectionMetadataRow.test.tsx create mode 100644 src/components/connections/ConnectionMetadataRow.tsx create mode 100644 src/components/connections/ProviderSetupGuide.test.tsx create mode 100644 src/components/connections/ProviderSetupGuide.tsx create mode 100644 src/components/connections/ProviderTabsGrid.test.tsx create mode 100644 src/components/connections/ProviderTabsGrid.tsx delete mode 100644 src/components/connections/SimpleConnectionForm.test.tsx delete mode 100644 src/components/connections/SimpleConnectionForm.tsx create mode 100644 src/components/connections/SqlitePathInput.test.tsx create mode 100644 src/components/connections/SqlitePathInput.tsx create mode 100644 src/components/db-viewer/EditConnectionModal.test.tsx create mode 100644 src/lib/dbCapabilities.test.ts create mode 100644 src/lib/dbCapabilities.ts create mode 100644 src/lib/providers.test.ts create mode 100644 src/lib/providers.ts create mode 100644 src/lib/uiConstants.test.ts create mode 100644 src/lib/uiConstants.ts diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..9b480e9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -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: '' +--- + + + +## 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 `` + +## Summary + + + +## Environment + +| Field | Value | +| :--- | :--- | +| OS + version | | +| Gridline version | | +| Install type | | +| Database type | | +| Database version | | +| Where the DB runs | | +| Connection method | | +| Gridline architecture | | + +## What did you expect to happen? + + + +## What actually happened? + + + +## Steps to reproduce + + + +1. Open Gridline and connect to … +2. Click … +3. … + +## Screenshots / recordings + + + +## Logs & error messages + + + +``` +(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 + + + +## Additional context + + \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 795614c..757feaf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 — diff --git a/AGENTS.md b/AGENTS.md index 0cf0ade..a566066 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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   🟡 = Partial/Stub   ❌ = 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:` / `ssh_passphrase:`), 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/) diff --git a/README.md b/README.md index 6ff5ea0..a1e5e4a 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

Gridline

- A lightweight, open-source database GUI for PostgreSQL and SQLite.
+ A lightweight, open-source database GUI for PostgreSQL, MySQL, SQLite, and Redis.
Unlimited connections, tabs, and saved queries — with first-class pg_dump, pg_restore, and DB-to-DB sync.

@@ -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. --- diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..210fb97 --- /dev/null +++ b/ROADMAP.md @@ -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 \ No newline at end of file diff --git a/package.json b/package.json index 2312a4b..83e250d 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/public/tauri.svg b/public/tauri.svg deleted file mode 100644 index 31b62c9..0000000 --- a/public/tauri.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/public/vite.svg b/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 18305d1..84b3ff8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1783,7 +1783,7 @@ dependencies = [ [[package]] name = "gridline" -version = "0.6.0" +version = "0.7.0" dependencies = [ "chrono", "deadpool-postgres", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 69a87b8..f5bb29e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" } diff --git a/src-tauri/src/commands/db_viewer.rs b/src-tauri/src/commands/db_viewer.rs index 43916c1..c2ff8ae 100644 --- a/src-tauri/src/commands/db_viewer.rs +++ b/src-tauri/src/commands/db_viewer.rs @@ -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, + pool_manager: &tokio::sync::Mutex, +) -> 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 = 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 = 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, - 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, + connection_id: &str, + schema: Option<&str>, ) -> Result, 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::, _>>() .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, + state: State<'_, crate::AppState>, +) -> Result, 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, 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 = 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 = r + .try_get::(4) + .ok() + .or_else(|| r.try_get::, _>(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::::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, + connection_id: &str, + schema: &str, + table: &str, page: Option, page_size: Option, filters: Option>, sorts: Option>, - state: State<'_, crate::AppState>, ) -> Result { 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 = 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> = 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, + page_size: Option, + filters: Option>, + sorts: Option>, + state: State<'_, crate::AppState>, +) -> Result { + 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> = 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) = 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 = + rows.iter().flatten().cloned().collect(); + // Bulk insert affects many rows — skip the single-row + // affected-count guard. + bind_mysql_params(sqlx::query(&sql), ¶ms) + .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), ¶ms) + .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 { + 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()); + } } diff --git a/src-tauri/src/commands/query.rs b/src-tauri/src/commands/query.rs index 1695f59..da0ed39 100644 --- a/src-tauri/src/commands/query.rs +++ b/src-tauri/src/commands/query.rs @@ -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 () 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 () 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::, _>(i) { + return v; + } + if let Ok(s) = row.try_get::, _>(i) { + return s + .map(|s| serde_json::Value::String(s)) + .unwrap_or(serde_json::Value::Null); + } + if let Ok(b) = row.try_get::>, _>(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 { + 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 = 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> = 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 { + let rows = sqlx::query(query) + .fetch_all(pool) + .await + .map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))?; + + let columns: Vec = 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> = 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> = 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"); + } } diff --git a/src-tauri/src/commands/schema_graph.rs b/src-tauri/src/commands/schema_graph.rs index b1386e5..7216ac8 100644 --- a/src-tauri/src/commands/schema_graph.rs +++ b/src-tauri/src/commands/schema_graph.rs @@ -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()), } } diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs index 3b72578..b0526ed 100644 --- a/src-tauri/src/db/mod.rs +++ b/src-tauri/src/db/mod.rs @@ -1,4 +1,5 @@ pub mod introspection; +pub mod mysql; pub mod pool; pub mod tls; diff --git a/src-tauri/src/db/mysql.rs b/src-tauri/src/db/mysql.rs new file mode 100644 index 0000000..b7182db --- /dev/null +++ b/src-tauri/src/db/mysql.rs @@ -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::(i) { + return s; + } + if let Ok(b) = row.try_get::, _>(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 = 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 = 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), 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 = Vec::new(); + let set_clause: Vec = new_data + .iter() + .map(|(col, val)| { params.push(val.clone()); format!("{} = ?", mysql_quote_ident(col)) }) + .collect(); + let where_clause: Vec = 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), 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 = Vec::new(); + let where_clause: Vec = 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) { + let cols: Vec = pairs.iter().map(|(c, _)| mysql_quote_ident(c)).collect(); + let placeholders: Vec<&str> = pairs.iter().map(|_| "?").collect(); + let params: Vec = 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 = columns.iter().map(|c| mysql_quote_ident(c)).collect(); + let one_row = format!("({})", columns.iter().map(|_| "?").collect::>().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(&[]), ""); + } +} \ No newline at end of file diff --git a/src-tauri/src/db/pool.rs b/src-tauri/src/db/pool.rs index bec30c9..4cfb655 100644 --- a/src-tauri/src/db/pool.rs +++ b/src-tauri/src/db/pool.rs @@ -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(_)) + )); + } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index eab9209..bfdd95a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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", diff --git a/src/components/connections/ConnectionCard.test.tsx b/src/components/connections/ConnectionCard.test.tsx index ce9e891..ff90d56 100644 --- a/src/components/connections/ConnectionCard.test.tsx +++ b/src/components/connections/ConnectionCard.test.tsx @@ -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( + , + { 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( + , + { wrapper: Wrapper }, + ); + const row = container.querySelector('[data-testid="tag-row"]'); + expect(row?.className).not.toContain("overflow-x-auto"); + }); }); diff --git a/src/components/connections/ConnectionCard.tsx b/src/components/connections/ConnectionCard.tsx index b5f3dca..2d75a39 100644 --- a/src/components/connections/ConnectionCard.tsx +++ b/src/components/connections/ConnectionCard.tsx @@ -134,9 +134,14 @@ function ConnectionCardBase({
{hostLabel}
-
+
3 ? "overflow-x-auto" : "flex-wrap"} max-h-[28px] whitespace-nowrap`} + > {cardTags.map((t) => ( - + + + ))}
diff --git a/src/components/connections/ConnectionFormShell.tsx b/src/components/connections/ConnectionFormShell.tsx index 2b2aa7c..5ddceaf 100644 --- a/src/components/connections/ConnectionFormShell.tsx +++ b/src/components/connections/ConnectionFormShell.tsx @@ -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,17 +49,7 @@ export function ConnectionFormShell({ {saveLoading ? "Saving..." : "Save Connection"} - - ); -} +} \ No newline at end of file diff --git a/src/components/connections/ConnectionMetadataRow.test.tsx b/src/components/connections/ConnectionMetadataRow.test.tsx new file mode 100644 index 0000000..6b906ef --- /dev/null +++ b/src/components/connections/ConnectionMetadataRow.test.tsx @@ -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 ( + { + onChange(updates); + setForm((prev) => ({ ...prev, ...updates })); + }} + /> + ); + } + render(); + 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(); + 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(); + 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(); + 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" })); + }); +}); \ No newline at end of file diff --git a/src/components/connections/ConnectionMetadataRow.tsx b/src/components/connections/ConnectionMetadataRow.tsx new file mode 100644 index 0000000..4586a30 --- /dev/null +++ b/src/components/connections/ConnectionMetadataRow.tsx @@ -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) => 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 ( +
+
+ + onChange({ name: value })} + placeholder="My Production Database" + aria-label="Connection Label" + /> +
+ +
+ + +
+ + {openPanel === "tags" && ( + { + const current = form.tag_ids ?? []; + const next = current.includes(tagId) ? current.filter((id) => id !== tagId) : [...current, tagId]; + onChange({ tag_ids: next }); + }} + /> + )} + {openPanel === "env" && ( +
+ + onChange({ environment: value })} /> +
+ )} + +
+ + onChange({ folder_id: value })} /> +
+
+ ); +} \ No newline at end of file diff --git a/src/components/connections/DetailedConnectionForm.test.tsx b/src/components/connections/DetailedConnectionForm.test.tsx index ecad5cd..6e40fd0 100644 --- a/src/components/connections/DetailedConnectionForm.test.tsx +++ b/src/components/connections/DetailedConnectionForm.test.tsx @@ -26,6 +26,8 @@ const BASE_FORM: ConnectionFormData = { function StatefulForm( props: Omit & { onChange?: (updates: Partial) => void; + folders?: unknown; + tags?: unknown; }, ) { const [form, setForm] = useState(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(); + 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( {}} />); + expect(screen.getByLabelText(/connection label/i)).toBeInTheDocument(); + }); + + it("updates host via the General tab", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + await user.type(screen.getByLabelText(/host/i), "localhost"); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ host: "localhost" })); + }); }); \ No newline at end of file diff --git a/src/components/connections/DetailedConnectionForm.tsx b/src/components/connections/DetailedConnectionForm.tsx index 6232e4a..c3f656b 100644 --- a/src/components/connections/DetailedConnectionForm.tsx +++ b/src/components/connections/DetailedConnectionForm.tsx @@ -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) => 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 ( -
-
- - - +
+ +
+
+ + +
+ {activeTab === "general" ? ( + + ) : ( + } onChange={onChange as (u: Record) => void} /> + )}
- - {activeTab === "general" ? ( - - ) : activeTab === "ssh" ? ( - } onChange={onChange as (updates: Record) => void} /> - ) : ( - - )}
); } \ No newline at end of file diff --git a/src/components/connections/GeneralTab.test.tsx b/src/components/connections/GeneralTab.test.tsx index 955968b..3c14f69 100644 --- a/src/components/connections/GeneralTab.test.tsx +++ b/src/components/connections/GeneralTab.test.tsx @@ -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(); - - 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( {}} />); + 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( {}} />); - + 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( {}} />); + it("emits host changes", () => { + const onChange = vi.fn(); + render(); + 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( {}} />); + 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(); + 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( {}} />); + expect(screen.getByText(/requires ssl/i)).toBeInTheDocument(); }); }); \ No newline at end of file diff --git a/src/components/connections/GeneralTab.tsx b/src/components/connections/GeneralTab.tsx index 0d2da00..7e1c2f7 100644 --- a/src/components/connections/GeneralTab.tsx +++ b/src/components/connections/GeneralTab.tsx @@ -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) => 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 (
- - onChange({ name: value })} - placeholder="My Production Database" - aria-label="Name" - /> + + {isSqlite ? ( + onChange({ host: value })} /> + ) : ( + 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 && ( +

+ {managedPreset === "supabase" ? "Supabase" : "NeonDB"} requires SSL — enable it under SSH / SSL. +

+ )}
{!isSqlite && ( -
-
- - onChange({ host: value })} - placeholder="localhost" - aria-label="Host" - /> + <> +
+
+ OR +
-
- - onChange({ port: value === "" ? null : Number(value) })} - placeholder="5432" - aria-label="Port" - /> +
+
+ + onChange({ host: value })} placeholder="localhost" aria-label="Host" /> +
+
+ + onChange({ port: value === "" ? null : Number(value) })} placeholder="5432" aria-label="Port" /> +
-
+
+ + +
+
+ + onChange({ username: value || null })} placeholder="postgres" aria-label="User" /> +
+
+ + onChange({ password: value || null })} placeholder="••••••••" aria-label="Password" /> +
+
+ + onChange({ database: value || null })} placeholder="database" aria-label="Database" /> +
+ )} -
- - -
- -
- - onChange({ username: value || null })} - placeholder="postgres" - aria-label="User" - /> -
- -
- - onChange({ password: value || null })} - placeholder="••••••••" - aria-label="Password" - /> -
- -
- - onChange({ database: value || null })} - placeholder="database" - aria-label="Database" - /> -
-
diff --git a/src/components/connections/NewConnectionScreen.test.tsx b/src/components/connections/NewConnectionScreen.test.tsx index 6d64750..d0e71ea 100644 --- a/src/components/connections/NewConnectionScreen.test.tsx +++ b/src/components/connections/NewConnectionScreen.test.tsx @@ -4,11 +4,12 @@ import userEvent from "@testing-library/user-event"; import { NewConnectionScreen } from "./NewConnectionScreen"; import { useSettingsStore } from "../../stores/settingsStore"; -const { createConnection, notify, testConnection } = vi.hoisted(() => ({ - createConnection: vi.fn().mockResolvedValue({}), - notify: vi.fn(), - testConnection: vi.fn().mockResolvedValue({ ok: true }), -})); +const { createConnection, notify, testConnection } = vi.hoisted(() => + ({ + createConnection: vi.fn().mockResolvedValue({}), + notify: vi.fn(), + testConnection: vi.fn().mockResolvedValue({ ok: true }), + })); vi.mock("../../stores/connectionStore", () => ({ createConnection, @@ -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(); - - 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(); + 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(); - - 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(); - 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( - , - ); - - expect(screen.getByLabelText(/connection string/i)).toHaveValue( - "postgresql://u:p@localhost:5432/db", - ); - - await user.click(screen.getByText(/configure manually instead/i)); - + render(); + 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(); - await user.click(screen.getByText("Save Connection")); + render(); + 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(); + 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(); + 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(); - - await user.type(screen.getByLabelText("Connection Label"), "Local DB"); - await user.type( - screen.getByLabelText("Connection String"), - "postgresql://u:p@localhost:5432/db", - ); + render(); + 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(); - - await user.type(screen.getByLabelText("Connection Label"), "Local DB"); - await user.type( - screen.getByLabelText("Connection String"), - "postgresql://u:p@localhost:5432/db", - ); + render(); + 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(); - + render(); await user.click(screen.getByRole("button", { name: "Back" })); expect(onCancel).toHaveBeenCalled(); }); + + it("prefills from prefilledConnectionString and reveals the form", async () => { + render(); + 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"); + }); }); \ No newline at end of file diff --git a/src/components/connections/NewConnectionScreen.tsx b/src/components/connections/NewConnectionScreen.tsx index 8a08225..193662d 100644 --- a/src/components/connections/NewConnectionScreen.tsx +++ b/src/components/connections/NewConnectionScreen.tsx @@ -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 = { + postgresql: 5432, + mysql: 3306, + redis: 6379, +}; + function createEmptyForm( defaultFolderId: string | null = null, defaultPorts?: Record, @@ -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("simple"); + const [stage, setStage] = useState("entry"); + const [managedPreset, setManagedPreset] = useState< + "supabase" | "neon" | null + >(null); const [form, setForm] = useState(() => 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) => { + 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 { + const parsed = parseConnectionString(value); + 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) => { - 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) => { + 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,44 +258,82 @@ export function NewConnectionScreen({ } }, [validate, notify, testConnection, buildPayload]); - const onSimpleChange = useCallback( - (updates: Partial) => { - 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 ( onCancel?.()} onTest={handleTest} onSave={handleSave} - onToggleMode={onToggleMode} testLoading={testLoading} saveLoading={saveLoading} > - {mode === "simple" ? ( - +
+ {isEntry && showEntryUri && ( + + )} + {isEntry && form.db_type === "sqlite" ? ( +
+ + + 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`} + /> +
+ ) : ( + + 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 && ( +

+ Paste a connection string to auto-detect, or pick a + provider below. +

+ )} +
+ + {isEntry ? ( + <> +
+
+ OR +
+
+ + ) : ( - + <> + {managedPreset && ( + + )} + + )} ); -} +} \ No newline at end of file diff --git a/src/components/connections/PasswordInput.tsx b/src/components/connections/PasswordInput.tsx index 3d5294c..2dd18d6 100644 --- a/src/components/connections/PasswordInput.tsx +++ b/src/components/connections/PasswordInput.tsx @@ -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( onChange?.(e.target.value)} onKeyDown={(e) => onKeyDown?.(e)} {...rest} diff --git a/src/components/connections/ProviderSetupGuide.test.tsx b/src/components/connections/ProviderSetupGuide.test.tsx new file mode 100644 index 0000000..aa8ed24 --- /dev/null +++ b/src/components/connections/ProviderSetupGuide.test.tsx @@ -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(); + 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(); + 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(); + 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(); + }); +}); \ No newline at end of file diff --git a/src/components/connections/ProviderSetupGuide.tsx b/src/components/connections/ProviderSetupGuide.tsx new file mode 100644 index 0000000..1ad52a6 --- /dev/null +++ b/src/components/connections/ProviderSetupGuide.tsx @@ -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 ( +
+ + + {guide.sslRequired && ( +

{SSL_NOTE[provider]}

+ )} + + {open && ( +
    + {guide.steps.map((step, i) => ( +
  1. + {step.title} +

    {step.detail}

    +
  2. + ))} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/connections/ProviderTabsGrid.test.tsx b/src/components/connections/ProviderTabsGrid.test.tsx new file mode 100644 index 0000000..f4d3ae3 --- /dev/null +++ b/src/components/connections/ProviderTabsGrid.test.tsx @@ -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( {}} />); + 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( {}} />); + 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(); + await user.click(screen.getByRole("button", { name: /MySQL/i })); + expect(onSelect).toHaveBeenCalledWith("mysql"); + }); + + it("highlights the selected provider", () => { + render( {}} 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( {}} />); + expect(screen.getByText(/managed postgresql/i)).toBeInTheDocument(); + expect(screen.getByText(/serverless postgres/i)).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/src/components/connections/ProviderTabsGrid.tsx b/src/components/connections/ProviderTabsGrid.tsx new file mode 100644 index 0000000..a4fc9fb --- /dev/null +++ b/src/components/connections/ProviderTabsGrid.tsx @@ -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 ( +
+ {PROVIDER_TABS.map((p) => { + const isSelected = selectedId === p.id; + return ( + + ); + })} +
+ ); +} \ No newline at end of file diff --git a/src/components/connections/SimpleConnectionForm.test.tsx b/src/components/connections/SimpleConnectionForm.test.tsx deleted file mode 100644 index 7dca49c..0000000 --- a/src/components/connections/SimpleConnectionForm.test.tsx +++ /dev/null @@ -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 & { - onChange?: (updates: Partial) => void; - }, -) { - const [form, setForm] = useState(BASE_FORM); - return ( - { - setForm((prev) => ({ ...prev, ...updates })); - props.onChange?.(updates); - }} - /> - ); -} - -describe("SimpleConnectionForm", () => { - it("updates the connection string", async () => { - const user = userEvent.setup(); - const onChange = vi.fn(); - render(); - 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(); - - 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: [] }); - }); -}); diff --git a/src/components/connections/SimpleConnectionForm.tsx b/src/components/connections/SimpleConnectionForm.tsx deleted file mode 100644 index 4a74ce7..0000000 --- a/src/components/connections/SimpleConnectionForm.tsx +++ /dev/null @@ -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) => void; -} - -export function SimpleConnectionForm({ - form, - folders, - tags, - onChange, -}: SimpleConnectionFormProps) { - return ( -
-
- - onChange({ name: value })} - placeholder="My Production Database" - aria-label="Connection Label" - /> -

- A friendly name to identify this connection. -

-
- -
- - onChange({ environment: value })} - /> -
- -
- - onChange({ folder_id: value })} - /> -
- - { - const current = form.tag_ids ?? []; - const next = current.includes(tagId) - ? current.filter((id) => id !== tagId) - : [...current, tagId]; - onChange({ tag_ids: next }); - }} - /> - -
- - onChange({ connection_string: value })} - placeholder="postgresql://user:password@host:5432/database" - aria-label="Connection String" - /> -

- Paste your connection string to auto-detect database type. -

-
-
- ); -} diff --git a/src/components/connections/SqlitePathInput.test.tsx b/src/components/connections/SqlitePathInput.test.tsx new file mode 100644 index 0000000..0a2eadc --- /dev/null +++ b/src/components/connections/SqlitePathInput.test.tsx @@ -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, "onChange"> & { + onChange?: (value: string) => void; +}) { + const [value, setValue] = useState(props.value ?? ""); + return ( + { + 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(); + 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(); + 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(); + await user.click(screen.getByRole("button", { name: /browse/i })); + expect(onChange).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/src/components/connections/SqlitePathInput.tsx b/src/components/connections/SqlitePathInput.tsx new file mode 100644 index 0000000..afb790f --- /dev/null +++ b/src/components/connections/SqlitePathInput.tsx @@ -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 ( +
+ 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`} + /> + +
+ ); +} \ No newline at end of file diff --git a/src/components/db-viewer/DbViewerScreen.test.tsx b/src/components/db-viewer/DbViewerScreen.test.tsx index a895b1d..9017860 100644 --- a/src/components/db-viewer/DbViewerScreen.test.tsx +++ b/src/components/db-viewer/DbViewerScreen.test.tsx @@ -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( + {}} + 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( + {}} + 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( + {}} + onSettings={() => {}} + />, + ); + fireEvent.click(screen.getByLabelText(/objects/i)); + rerender( + {}} + onSettings={() => {}} + />, + ); + expect( + screen.getByText(/objects is unsupported for mysql/i), + ).toBeInTheDocument(); + }); }); \ No newline at end of file diff --git a/src/components/db-viewer/DbViewerScreen.tsx b/src/components/db-viewer/DbViewerScreen.tsx index 87ee797..e370f33 100644 --- a/src/components/db-viewer/DbViewerScreen.tsx +++ b/src/components/db-viewer/DbViewerScreen.tsx @@ -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(
)} - {activeTab?.data && ( + {activeTab && (
{connectionError && connectionError !== dismissedError && ( @@ -1371,7 +1377,17 @@ const onQueriesPanelResizeStart = useCallback( onDismiss={() => setDismissedError(connectionError)} /> )} - {currentView === "db-viewer" ? ( + {isRedisUnsupported ? ( +
+ + Redis browsing isn't supported yet — this connection can be tested and used from the Home screen. +
+ ) : !viewSupported ? ( +
+ + {currentView.replace("-", " ")} is unsupported for {currentConnection?.db_type} +
+ ) : currentView === "db-viewer" ? (
{ 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( + + {}} /> + + ); + 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( + + {}} capabilities={DB_CAPABILITIES.sqlite} /> + + ); + 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( + + {}} capabilities={DB_CAPABILITIES.mysql} /> + + ); + 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( + + {}} capabilities={DB_CAPABILITIES.redis} /> + + ); + 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(); + }); }); \ No newline at end of file diff --git a/src/components/db-viewer/DbViewerSidebar.tsx b/src/components/db-viewer/DbViewerSidebar.tsx index b6d0d4d..a966342 100644 --- a/src/components/db-viewer/DbViewerSidebar.tsx +++ b/src/components/db-viewer/DbViewerSidebar.tsx @@ -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 = { + "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: }, @@ -41,6 +52,10 @@ export function DbViewerSidebar({ { id: "tools", label: "Tools", icon: }, ]; + const visibleTopItems = topItems.filter( + (item) => capabilities[NAV_CAPABILITY_KEY[item.id]] + ); + const bottomItems: NavItem[] = [ { id: "home", label: "Home", icon: }, { id: "settings", label: "Settings", icon: }, @@ -73,7 +88,7 @@ export function DbViewerSidebar({ return (
- {topItems.map(renderItem)} + {visibleTopItems.map(renderItem)}
{bottomItems.map(renderItem)} diff --git a/src/components/db-viewer/DbViewerToolbar.test.tsx b/src/components/db-viewer/DbViewerToolbar.test.tsx index 0f300e6..2628431 100644 --- a/src/components/db-viewer/DbViewerToolbar.test.tsx +++ b/src/components/db-viewer/DbViewerToolbar.test.tsx @@ -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( + + + , + ); + 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( + + + , + ); + expect(screen.getByText("public")).toBeInTheDocument(); + }); }); \ No newline at end of file diff --git a/src/components/db-viewer/DbViewerToolbar.tsx b/src/components/db-viewer/DbViewerToolbar.tsx index 17b96c0..c7cc8be 100644 --- a/src/components/db-viewer/DbViewerToolbar.tsx +++ b/src/components/db-viewer/DbViewerToolbar.tsx @@ -43,6 +43,7 @@ export function DbViewerToolbar({ const searchInputRef = useRef(null); const searchContainerRef = useRef(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 (
- {(databases.length > 1 || schemas.length > 1) && ( + {(databases.length > 1 || schemas.length > 1 || schemaTreeLoading) && (
{databases.length > 1 && ( )} - {databases.length > 1 && schemas.length > 1 && ( + {databases.length > 1 && (schemas.length > 1 || schemaTreeLoading) && ( | )} - {schemas.length > 1 && ( + {(schemas.length > 1 || schemaTreeLoading) && ( ({ - 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} /> )}
diff --git a/src/components/db-viewer/EditConnectionModal.test.tsx b/src/components/db-viewer/EditConnectionModal.test.tsx new file mode 100644 index 0000000..2cae565 --- /dev/null +++ b/src/components/db-viewer/EditConnectionModal.test.tsx @@ -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( + {}} + 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( + {}} + onSaved={() => {}} + /> + ); + expect(screen.getByText(/requires ssl/i)).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/src/components/db-viewer/EditConnectionModal.tsx b/src/components/db-viewer/EditConnectionModal.tsx index 9a7c842..f8c2e58 100644 --- a/src/components/db-viewer/EditConnectionModal.tsx +++ b/src/components/db-viewer/EditConnectionModal.tsx @@ -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(() => ({ name: connection.name, environment: (connection.environment as ConnectionFormData["environment"]) ?? null, @@ -132,7 +135,7 @@ export function EditConnectionModal({

Edit Connection

- setForm((prev) => ({ ...prev, ...updates }))} /> + setForm((prev) => ({ ...prev, ...updates }))} managedPreset={managedPreset} />