6 Commits
Author SHA1 Message Date
adrianbonpin 9e54240cd6 docs: reflect live website in roadmap (landing + changelog shipped, docs remain) 2026-09-06 13:48:12 +08:00
adrianbonpin cb86caba9e docs: define 0.8.0 scope and reorganize ROADMAP.md
- 0.8.0: schema diff/compare, multiple result sets, result streaming,
  MariaDB, TimescaleDB, PlanetScale preset (Turso dropped — needs a
  libsql driver path, stays in the queue)
- Reorganized: roadmap items up top, all shipped sections consolidated
  below; removed entries redundant with versioned sections
- Fixed stale references (settings import/export shipped in 0.7.8,
  tense fixes in 0.7.8 section, trimmed 0.7.0 deferred list)
2026-09-06 13:46:31 +08:00
adrianbonpin 60d472dde2 fix: use tea CLI for macOS release upload + test landing.js version (#26) 2026-09-06 05:30:30 +00:00
adrianbonpin bdf2bb543b Release 0.7.15 (#25)
Release / windows (push) Successful in 17m44s
Release / linux (push) Successful in 21m9s
2026-09-06 04:58:10 +00:00
adrianbonpin 67f3532938 fix: release-mac.sh resolves tokens from the git credential store; fail fast (#24) 2026-09-04 04:21:16 +00:00
adrianbonpin 1d8dfce99a fix: make release-mac.sh idempotent for re-runs (#23) 2026-09-04 04:10:29 +00:00
33 changed files with 692 additions and 408 deletions
+3 -3
View File
@@ -290,7 +290,7 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
| Smart default sort | ✅ | 12-tier priority: updated_at → created_at → *_at → *_id → seq/rank/version | | Smart default sort | ✅ | 12-tier priority: updated_at → created_at → *_at → *_id → seq/rank/version |
| Data grid pagination | ✅ | Page nav, page size selector persisted in settings | | Data grid pagination | ✅ | Page nav, page size selector persisted in settings |
| Column filtering (server-side) | ✅ | eq, neq, contains, starts, ends, gt, lt, null, notnull pushed to SQL WHERE | | Column filtering (server-side) | ✅ | eq, neq, contains, starts, ends, gt, lt, null, notnull pushed to SQL WHERE |
| Visual filter builder | ✅ | Drag-and-drop column palette (@dnd-kit) with type-aware operators (textish → contains, else eq), AND semantics, persists in tab `filterRules` | | Visual filter builder | ✅ | Column Filters popover with a "+ Add filter" row editor (column / operator / value selects, per-rule remove, AND semantics, persists in tab `filterRules`). Free-text values are debounced (400ms) so a query only fires after the user stops typing; column/operator changes apply immediately. The filter button is a plain toggle (open when closed, close when open) |
| Column sorting (server-side) | ✅ | Multi-column asc/desc pushed to SQL ORDER BY | | Column sorting (server-side) | ✅ | Multi-column asc/desc pushed to SQL ORDER BY |
| Column show/hide | ✅ | Toggle visibility per column | | Column show/hide | ✅ | Toggle visibility per column |
| Column resize (drag handle) | ✅ | Double-click to auto-fit | | Column resize (drag handle) | ✅ | Double-click to auto-fit |
@@ -298,13 +298,13 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
| Export toolbar (JSON, CSV, SQL, Markdown, Excel (.xlsx)) | ✅ | Client-side Blob download of visible rows; Excel (.xlsx) via client-side workbook generation (v0.7.8) | | Export toolbar (JSON, CSV, SQL, Markdown, Excel (.xlsx)) | ✅ | Client-side Blob download of visible rows; Excel (.xlsx) via client-side workbook generation (v0.7.8) |
| Excel (.xlsx) export | ✅ | Client-side .xlsx export of visible rows alongside JSON/CSV/SQL/Markdown (v0.7.8) | | Excel (.xlsx) export | ✅ | Client-side .xlsx export of visible rows alongside JSON/CSV/SQL/Markdown (v0.7.8) |
| Auto-refresh timer | ✅ | Configurable interval in settings | | Auto-refresh timer | ✅ | Configurable interval in settings |
| Changes queue (INSERT, UPDATE, DELETE, bulk_insert, empty_table, drop_table) | ✅ | Stage → **Commit All**. Tab bar **Changes** button (amber border + count badge when pending) toggles a **popover** anchored to it: header with **Visual/SQL** toggle (cards showing op badge + table + description + per-change **Revert**, or a generated-SQL preview via `buildChangeSql`), footer **Clear All** + **Commit All (N)** with **⌘S/Ctrl+S** shortcut. Committed cards show a green ✓ (failed ✗); committing `drop_table` auto-closes open tabs of that table | | Changes queue (INSERT, UPDATE, DELETE, bulk_insert, empty_table, drop_table) | ✅ | Stage → **Commit All**. Tab bar **Changes** button (amber border + count badge when pending) toggles a **popover** anchored to it: header with **Visual/SQL** toggle (cards showing op badge + table + description + per-change **Revert**, or a generated-SQL preview via `buildChangeSql`), footer **Clear All** + **Commit All (N)** with **⌘S/Ctrl+S** shortcut. Committed cards show a green ✓ (failed ✗); committing `drop_table` auto-closes open tabs of that table. **Insert Row** adds an editable pending row at the top of the grid (accent outline); committing inserts only the filled columns so serial/identity/generated defaults apply |
| Auto schema-tree refresh | ✅ | Tree auto-refreshes after a successful schema-modifying query run (`CREATE`/`DROP`/`ALTER`/`TRUNCATE` via `isSchemaModifyingQuery`) and after committing schema-modifying queue changes — `drop_table`, schema-modifying `ddl` (e.g. `CREATE TABLE`), and `rebuild_table` — no manual refresh needed | | Auto schema-tree refresh | ✅ | Tree auto-refreshes after a successful schema-modifying query run (`CREATE`/`DROP`/`ALTER`/`TRUNCATE` via `isSchemaModifyingQuery`) and after committing schema-modifying queue changes — `drop_table`, schema-modifying `ddl` (e.g. `CREATE TABLE`), and `rebuild_table` — no manual refresh needed |
| Data import (CSV/JSON) | ✅ | Table overflow menu → ImportDialog: file pick, parse, preview (first 100 rows), header→column mapping, caps 100k rows / 100 MB; stages a bulk_insert change through the queue → Commit All | | Data import (CSV/JSON) | ✅ | Table overflow menu → ImportDialog: file pick, parse, preview (first 100 rows), header→column mapping, caps 100k rows / 100 MB; stages a bulk_insert change through the queue → Commit All |
| Table menu actions | ✅ | Copy table schema (DDL via pg_dump / sqlite_master), Empty Table (DELETE) / Delete Table (DROP) through the queue with confirm, export stubs wired (JSON/CSV/SQL/Markdown) | | Table menu actions | ✅ | Copy table schema (DDL via pg_dump / sqlite_master), Empty Table (DELETE) / Delete Table (DROP) through the queue with confirm, export stubs wired (JSON/CSV/SQL/Markdown) |
| Edit connection modal (from DB viewer) | ✅ | AnimatedModal with keychain password fetch on test | | Edit connection modal (from DB viewer) | ✅ | AnimatedModal with keychain password fetch on test |
| Connection drop banner | ✅ | Auto-detects broken connections with reconnect prompt | | Connection drop banner | ✅ | Auto-detects broken connections with reconnect prompt |
| Inline cell editing | ✅ | Double-click/Enter edits a cell; commit stages an `update` change in the queue → Commit All. No-PK tables use ctid/rowid locator; PK/generated/identity columns and views/matviews are read-only. Stale-write protection via affected-row-count check. **Optimistic UI**: the changes queue is the single source of truth — `deriveStagedValues` feeds staged values + a pending amber dot back into the grid (dot clears on commit, values survive until refetch); re-editing the same cell replaces the queue entry (original `oldData` kept); Clear All removes dots instantly; queue cards show an old → new value diff. **Smart editors**: PG enum columns render a `<select>` of enum labels; FK columns render a searchable dropdown of referenced rows (one row per option showing the first 4 referenced columns, FK-popover styling, 360px fixed, portal to body); `text`/`json`/`jsonb` use a single-line scrolling textarea. | | Inline cell editing | ✅ | Double-click/Enter edits a cell; commit stages an `update` change in the queue → Commit All. No-PK tables use ctid/rowid locator; PK/generated/identity columns and views/matviews are read-only. Stale-write protection via affected-row-count check. **Optimistic UI**: the changes queue is the single source of truth — `deriveStagedValues` feeds staged values + a pending amber dot back into the grid (dot clears on commit, values survive until refetch); re-editing the same cell replaces the queue entry (original `oldData` kept); Clear All removes dots instantly; queue cards show an old → new value diff. **Smart editors**: PG enum columns render a `<select>` of enum labels; FK columns render a searchable dropdown of referenced rows (one row per option showing the first 4 referenced columns, FK-popover styling, 360px fixed, portal to body); `date`/`time`/`timestamp` columns use native date/time/datetime pickers, `boolean` a true/false select, and numeric types a number input; `text`/`json`/`jsonb` use a single-line scrolling textarea. |
| Virtualized data grid | ✅ | Row-level virtualization via @tanstack/react-virtual `useVirtualizer`; handles 100k+ rows | | Virtualized data grid | ✅ | Row-level virtualization via @tanstack/react-virtual `useVirtualizer`; handles 100k+ rows |
| Row detail / expandable row view | ✅ | RowDetailDrawer: right-drawer per row, opened via the cell context menu **View Row** | | Row detail / expandable row view | ✅ | RowDetailDrawer: right-drawer per row, opened via the cell context menu **View Row** |
| Keyboard cell navigation (arrow keys, Tab) | ✅ | Arrow keys + Tab/Shift+Tab wrap (`keyboardNav`); Enter opens the editor; **Esc cancels editing even after the editor lost focus** (document-level listener) and closes the context menu | | Keyboard cell navigation (arrow keys, Tab) | ✅ | Arrow keys + Tab/Shift+Tab wrap (`keyboardNav`); Enter opens the editor; **Esc cancels editing even after the editor lost focus** (document-level listener) and closes the context menu |
+19 -17
View File
@@ -30,16 +30,16 @@
## Download ## Download
Grab the installer for your OS from the [latest release](https://git.ranio.xyz/adrianbonpin/gridline/releases/latest) — the links below point at the current release (**v0.7.14**): Grab the installer for your OS from the [latest release](https://git.ranio.xyz/adrianbonpin/gridline/releases/latest) — the links below point at the current release (**v0.7.15**):
| OS | Architecture | Download | | OS | Architecture | Download |
| :--------------------------- | :--------------------------- | :----------------------------------------------------------------------------------------------------------------------------------- | | :--------------------------- | :--------------------------- | :----------------------------------------------------------------------------------------------------------------------------------- |
| **macOS** | Apple Silicon (M1/M2/M3/M4…) | [Gridline_0.7.14_aarch64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline_0.7.14_aarch64.dmg) | | **macOS** | Apple Silicon (M1/M2/M3/M4…) | [Gridline_0.7.15_aarch64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.15/Gridline_0.7.15_aarch64.dmg) |
| **macOS** | Intel | [Gridline_0.7.14_x64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline_0.7.14_x64.dmg) | | **macOS** | Intel | [Gridline_0.7.15_x64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.15/Gridline_0.7.15_x64.dmg) |
| **Windows** | x64 | [Gridline_0.7.14_x64-setup.exe](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline_0.7.14_x64-setup.exe) | | **Windows** | x64 | [Gridline_0.7.15_x64-setup.exe](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.15/Gridline_0.7.15_x64-setup.exe) |
| **Debian / Ubuntu** | amd64 | [Gridline_0.7.14_amd64.deb](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline_0.7.14_amd64.deb) | | **Debian / Ubuntu** | amd64 | [Gridline_0.7.15_amd64.deb](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.15/Gridline_0.7.15_amd64.deb) |
| **Fedora / RHEL / openSUSE** | x86_64 | [Gridline-0.7.14-1.x86_64.rpm](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline-0.7.14-1.x86_64.rpm) | | **Fedora / RHEL / openSUSE** | x86_64 | [Gridline-0.7.15-1.x86_64.rpm](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.15/Gridline-0.7.15-1.x86_64.rpm) |
| **Other Linux** | amd64 | [Gridline_0.7.14_amd64.AppImage](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline_0.7.14_amd64.AppImage) | | **Other Linux** | amd64 | [Gridline_0.7.15_amd64.AppImage](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.15/Gridline_0.7.15_amd64.AppImage) |
**macOS via Homebrew** (recommended for Mac users): **macOS via Homebrew** (recommended for Mac users):
@@ -57,7 +57,7 @@ The cask installs the app directly. The DMGs are Developer-ID signed and notariz
> **macOS first launch:** Gridline is Developer-ID signed and notarized, so macOS opens it without a Gatekeeper prompt. Not sure if your Mac is Intel or Apple Silicon? See [Which file should I download?](#which-file-should-i-download) below. > **macOS first launch:** Gridline is Developer-ID signed and notarized, so macOS opens it without a Gatekeeper prompt. Not sure if your Mac is Intel or Apple Silicon? See [Which file should I download?](#which-file-should-i-download) below.
<!-- <!--
MAINTENANCE: These links are STATIC (versioned) — they point at the v0.7.14 MAINTENANCE: These links are STATIC (versioned) — they point at the v0.7.15
release assets, not at a moving "latest" target. On every new release, release assets, not at a moving "latest" target. On every new release,
update BOTH tables here (Download + Which file should I download?) to the update BOTH tables here (Download + Which file should I download?) to the
new version's asset names, which are tauri-action's default naming: new version's asset names, which are tauri-action's default naming:
@@ -102,6 +102,8 @@ Gridline is built for developers and small teams who manage multiple database en
<details> <details>
<summary>View the full changelog</summary> <summary>View the full changelog</summary>
- **v0.7.15** — **Support channel on the landing page**: optional coffee via [Ko-fi](https://ko-fi.com/adrianbonpin), never gates a feature. Landing copy refreshed (em dashes dropped, OSS section simplified, funding FAQ); version bump.
- **v0.7.14** — **No more repeated macOS keychain prompts**: saved connection passwords and SSH secrets are now ACL-pinned to the app's code signature on every save (`keychain_acl`), so app upgrades never re-trigger the macOS keychain access dialog (users upgrading from pre-signing builds see one final prompt — choose **Always Allow**). **Install via Homebrew**: `brew tap AdrianBonpin/gridline https://git.ranio.xyz/adrianbonpin/homebrew-gridline.git` then `brew install --cask gridline`; version bump. - **v0.7.14** — **No more repeated macOS keychain prompts**: saved connection passwords and SSH secrets are now ACL-pinned to the app's code signature on every save (`keychain_acl`), so app upgrades never re-trigger the macOS keychain access dialog (users upgrading from pre-signing builds see one final prompt — choose **Always Allow**). **Install via Homebrew**: `brew tap AdrianBonpin/gridline https://git.ranio.xyz/adrianbonpin/homebrew-gridline.git` then `brew install --cask gridline`; version bump.
- **v0.7.13** — **macOS is now Developer-ID signed and notarized**: the app is properly code-signed with a Developer ID Application certificate and notarized by Apple, so macOS opens it without the Gatekeeper "Open Anyway" prompt or the `xattr` quarantine workaround. The release script (`scripts/release-mac.sh`) auto-detects the signing identity from the keychain, notarizes + staples both architectures, and verifies the result before uploading; version bump. - **v0.7.13** — **macOS is now Developer-ID signed and notarized**: the app is properly code-signed with a Developer ID Application certificate and notarized by Apple, so macOS opens it without the Gatekeeper "Open Anyway" prompt or the `xattr` quarantine workaround. The release script (`scripts/release-mac.sh`) auto-detects the signing identity from the keychain, notarizes + staples both architectures, and verifies the result before uploading; version bump.
@@ -338,16 +340,16 @@ Gridline's macOS builds are **Developer-ID signed and notarized**, so macOS open
#### Which file should I download? #### Which file should I download?
Each release contains **one file per platform** — you only need the one that matches your computer. The links below point at the current release (**v0.7.14**): Each release contains **one file per platform** — you only need the one that matches your computer. The links below point at the current release (**v0.7.15**):
| Your system | Download this | Notes | | Your system | Download this | Notes |
| :------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------- | | :------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------- |
| macOS **Apple Silicon** (M1/M2/M3/M4…) | [Gridline_0.7.14_aarch64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline_0.7.14_aarch64.dmg) | `aarch64` = Apple's own chip | | macOS **Apple Silicon** (M1/M2/M3/M4…) | [Gridline_0.7.15_aarch64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.15/Gridline_0.7.15_aarch64.dmg) | `aarch64` = Apple's own chip |
| macOS **Intel** | [Gridline_0.7.14_x64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline_0.7.14_x64.dmg) | `x64` = Intel/AMD | | macOS **Intel** | [Gridline_0.7.15_x64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.15/Gridline_0.7.15_x64.dmg) | `x64` = Intel/AMD |
| **Windows** (most PCs) | [Gridline_0.7.14_x64-setup.exe](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline_0.7.14_x64-setup.exe) | The `.msi` is an alternate installer (for enterprises/IT admins) | | **Windows** (most PCs) | [Gridline_0.7.15_x64-setup.exe](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.15/Gridline_0.7.15_x64-setup.exe) | The `.msi` is an alternate installer (for enterprises/IT admins) |
| **Debian / Ubuntu** | [Gridline_0.7.14_amd64.deb](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline_0.7.14_amd64.deb) | Install: `sudo apt install ./Gridline_0.7.14_amd64.deb` | | **Debian / Ubuntu** | [Gridline_0.7.15_amd64.deb](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.15/Gridline_0.7.15_amd64.deb) | Install: `sudo apt install ./Gridline_0.7.15_amd64.deb` |
| **Fedora / RHEL / openSUSE** | [Gridline-0.7.14-1.x86_64.rpm](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline-0.7.14-1.x86_64.rpm) | Install: `sudo dnf install Gridline-0.7.14-1.x86_64.rpm` | | **Fedora / RHEL / openSUSE** | [Gridline-0.7.15-1.x86_64.rpm](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.15/Gridline-0.7.15-1.x86_64.rpm) | Install: `sudo dnf install Gridline-0.7.15-1.x86_64.rpm` |
| **Any other Linux** | [Gridline_0.7.14_amd64.AppImage](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline_0.7.14_amd64.AppImage) | Works on every distro: `chmod +x` the file, then double-click it | | **Any other Linux** | [Gridline_0.7.15_amd64.AppImage](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.15/Gridline_0.7.15_amd64.AppImage) | Works on every distro: `chmod +x` the file, then double-click it |
**Not sure if your Mac is Intel or Apple Silicon?** Click the **Apple menu** → **About This Mac**. If it shows "Apple M1/M2/M3/M4…" download the `aarch64` file; if it shows an Intel chip, download `x64`. Downloading the wrong one won't run. **Not sure if your Mac is Intel or Apple Silicon?** Click the **Apple menu** → **About This Mac**. If it shows "Apple M1/M2/M3/M4…" download the `aarch64` file; if it shows an Intel chip, download `x64`. Downloading the wrong one won't run.
@@ -357,8 +359,8 @@ Cutting a release is one command — CI builds everything. **Releases are cut fr
```bash ```bash
git checkout prod && git pull git checkout prod && git pull
git tag v0.7.14 git tag v0.7.15
git push origin v0.7.14 git push origin v0.7.15
``` ```
Gitea Actions (`.gitea/workflows/release.yml`) builds the **Linux and Windows** installers on the self-hosted runners, then opens a **draft release** on the [Releases](https://git.ranio.xyz/adrianbonpin/gridline/releases) page. **macOS is built manually** on the dev Mac and its DMGs uploaded to the same release via `scripts/release-mac.sh`. Review the draft and hit **Publish release**. Gitea Actions (`.gitea/workflows/release.yml`) builds the **Linux and Windows** installers on the self-hosted runners, then opens a **draft release** on the [Releases](https://git.ranio.xyz/adrianbonpin/gridline/releases) page. **macOS is built manually** on the dev Mac and its DMGs uploaded to the same release via `scripts/release-mac.sh`. Review the draft and hit **Publish release**.
+47 -54
View File
@@ -6,38 +6,22 @@ This file is the **source of truth** for what Gridline is building. [AGENTS.md](
--- ---
## ✅ Shipped (0.7.10) ## 🎯 Next up (0.8.0)
- **Copy connection URL** — from a connection card's ⋮ menu, copy the connection string for use in env vars / other tools: **Copy connection URL** (includes the password, fetched from the OS keychain on demand) and **Copy connection URL (no password)** (safe to share). Builds `postgresql://` / `mysql://` / `sqlite://` / `redis://` strings with percent-encoded credentials and the PG `sslmode` appended. - **Schema diff / compare** — two-database structure diff (tables, columns, indexes, constraints, views) with generated sync SQL; pairs naturally with DB-to-DB sync
- **Version bump** 0.7.9 → **0.7.10**. - **Query Workbench: multiple result sets** — one query, stacked result tabs instead of only the last result *(deferred from 0.7.0)*
- **Query Workbench: result streaming to file** — export 500k+ rows without loading them all into memory
- **MariaDB support** — wire-compatible with MySQL; largely falls out of the v0.7.0 MySQL work (new `db_type`, provider card, version/introspection differences)
- **TimescaleDB support** — PostgreSQL extension; connects as regular PG, detect + surface hypertables/compression in the object tree
- **PlanetScale preset** — managed MySQL (Vitess) provider card following the Supabase/NeonDB pattern
## ✅ Shipped (0.7.9) Suggested build order: MariaDB → PlanetScale + TimescaleDB → query workbench (streaming, then multiple result sets) → schema diff.
- **macOS Finder launch fix** — the local store opens under the OS app-data directory instead of a cwd-relative `gridline.db` (Finder/LaunchServices launches run with cwd `/`, so the old path made the app silently exit with a Rust panic before the UI started).
- **macOS ad-hoc code signing** — bundles are signed at build time (`bundle.macOS.signingIdentity "-"`, hardened runtime off), replacing the Xcode linker-only signature that macOS treated as unsigned ("damaged and can't be opened", silent Finder refusal). Users still get a one-time Gatekeeper prompt; README documents the `sudo xattr -dr com.apple.quarantine` workaround.
- **Vendored OpenSSL for `ssh2`** — the release binary no longer links an absolute Homebrew `/opt/homebrew/.../libssl.3.dylib` path (dyld aborted on machines without it).
- **Version bump** 0.7.8 → **0.7.9**.
## 🎯 Next up (0.8.0) — TBD
Scope to be determined.
## ✅ Shipped (0.7.8)
- **SQLite `.dump` support** — match the pg_dump UX for SQLite (backup a SQLite database to a portable SQL dump, restore it back)
- **Backup / Restore / Sync for MySQL & SQLite** — extend the pg-only tooling today: SQLite backup/restore + MySQL via `mysqldump` (system-first; decide bundling)
- **Excel (.xlsx) export** — alongside CSV/JSON/SQL/Markdown in the grid export toolbar
- **Cancel long-running queries** — per-connection cancel button (`pg_cancel_backend` and equivalents) instead of waiting or killing the app
- **Settings export / import** — share theme, accent, editor options, page sizes, and defaults across machines (JSON file)
- **Windows/Linux title bar fix** — the macOS "Overlay" drag strip (`h-7` in `App.tsx`) renders on every Tauri platform, so Windows/Linux show a blank grabbable bar between the native title bar and the page; gate the strip to macOS only (native title bar already handles dragging elsewhere)
- **SQLite table editor (Create/Edit Table)** — the visual Create Table / Edit Table flow is PostgreSQL-only today (`tableManagement` capability + PG-flavored SQL gen in `TableForm`); extend to SQLite: SQLite-aware type mapping (no `serial` — `INTEGER PRIMARY KEY AUTOINCREMENT` instead), `TEXT`/`REAL`/`BLOB`, and ALTER TABLE limits (`ADD COLUMN` can't add PK/UNIQUE, `DROP COLUMN` needs SQLite ≥3.35)
- **Version bump** 0.7.7 → **0.7.8**.
## 📋 In the queue ## 📋 In the queue
### Full Redis Support ### 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. Connection + test only today — browsing stays gated behind a clean "unsupported" state. Goal: first-class Redis like the PG/SQLite/MySQL viewers.
- Key browser (pattern search, filter by type, TTL display, key count) - Key browser (pattern search, filter by type, TTL display, key count)
- Value editors per type: string, list, hash, set, zset, stream, JSON - Value editors per type: string, list, hash, set, zset, stream, JSON
@@ -46,28 +30,26 @@ Connection + test work today; **key browsing is explicitly not part of 0.7.0**
### Additional Database Types ### Additional Database Types
- **MariaDB** (wire-compatible with MySQL — should largely fall out of the 0.7.0 MySQL work) MariaDB and TimescaleDB moved to [0.8.0](#-next-up-080). Remaining candidates: CockroachDB, DuckDB, SQL Server, MongoDB (drivers are heavier lifts — revisit with demand).
- **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) ### Managed Database Support (beyond Supabase / Neon)
Supabase and NeonDB presets shipped in v0.7.0. Remaining candidates: Supabase and NeonDB presets shipped in v0.7.0; PlanetScale moved to [0.8.0](#-next-up-080). Remaining:
- **PlanetScale** (Vitess/MySQL) - **Turso** (libSQL — not SQLite over the wire; needs a `libsql` driver path in the Rust backend, not just a connection preset)
- **Turso** (libSQL)
- Provider detection guidance: paste a provider URL → Gridline auto-fills host/port/SSL mode; provider "connect" docs linked from the connection form - 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 ### Query Workbench Upgrades
- **Multiple result sets** — one query, multiple result tabs (stacked/scrollable) instead of only the last result *(deferred from 0.7.0)* Multiple result sets and result streaming to file moved to [0.8.0](#-next-up-080). Remaining:
- **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) - **Visual query builder** — drag-and-drop tables/joins/filters that generate SQL (TablePlus has one; DB Pro plans one)
### Schema & Data Tooling ### Schema & Data Tooling
- **Schema diff / compare** — two-database structure diff that pairs naturally with DB-to-DB sync Schema diff / compare moved to [0.8.0](#-next-up-080). Remaining:
- **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)*
- **MySQL Objects view + schema visualizer** — functions/triggers/sequences/enums/extensions browsing and ER diagram for MySQL *(PostgreSQL-only today)*
- **More export formats** — JSONL, Parquet alongside CSV/JSON/SQL/Markdown/Excel - **More export formats** — JSONL, Parquet alongside CSV/JSON/SQL/Markdown/Excel
## 🔮 Planned ## 🔮 Planned
@@ -84,9 +66,9 @@ Bring-your-own-key — no bundled model, no paywall, key stored in the OS keycha
### Website & Docs ### Website & Docs
- Landing page with screenshots, feature tour, and download links The marketing site (`www/`, Astro) is live: landing page with screenshots, FAQ, and a changelog feed. Remaining:
- User documentation (connection setup, SSH/TLS, backup/sync, changes queue) - User documentation (connection setup, SSH/TLS, backup/sync, changes queue)
- Blog / changelog feed
### UI/UX Improvements (rolling) ### UI/UX Improvements (rolling)
@@ -99,15 +81,37 @@ Continuous polish, tracked as issues rather than one-off milestones:
- Session restore — reopen tabs and query state from the last session - 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 - Light-theme parity pass — dark is first-class; polish the light theme to match
Deferred from 0.7.0, slated for this bucket: Still deferred from 0.7.0, slated for this bucket:
- **Onboarding tour** — first-run walkthrough built around the Gridline Demo database; contextual tooltips per screen - **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) - **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 - **In-app changelog** — "What's new" panel fed from bundled release notes
--- ---
## ✅ Shipped (0.7.10)
- **Copy connection URL** — from a connection card's ⋮ menu, copy the connection string for use in env vars / other tools: **Copy connection URL** (includes the password, fetched from the OS keychain on demand) and **Copy connection URL (no password)** (safe to share). Builds `postgresql://` / `mysql://` / `sqlite://` / `redis://` strings with percent-encoded credentials and the PG `sslmode` appended.
- **Version bump** 0.7.9 → **0.7.10**.
## ✅ Shipped (0.7.9)
- **macOS Finder launch fix** — the local store opens under the OS app-data directory instead of a cwd-relative `gridline.db` (Finder/LaunchServices launches run with cwd `/`, so the old path made the app silently exit with a Rust panic before the UI started).
- **macOS ad-hoc code signing** — bundles are signed at build time (`bundle.macOS.signingIdentity "-"`, hardened runtime off), replacing the Xcode linker-only signature that macOS treated as unsigned ("damaged and can't be opened", silent Finder refusal). Users still get a one-time Gatekeeper prompt; README documents the `sudo xattr -dr com.apple.quarantine` workaround.
- **Vendored OpenSSL for `ssh2`** — the release binary no longer links an absolute Homebrew `/opt/homebrew/.../libssl.3.dylib` path (dyld aborted on machines without it).
- **Version bump** 0.7.8 → **0.7.9**.
## ✅ Shipped (0.7.8)
- **SQLite `.dump` support** — backup a SQLite database to a portable SQL dump and restore it back, matching the pg_dump UX
- **Backup / Restore / Sync for MySQL & SQLite** — the pg-only tooling extended: MySQL backup/restore via `mysqldump` (system-first) and DB-to-DB sync beyond PostgreSQL
- **Excel (.xlsx) export** — alongside CSV/JSON/SQL/Markdown in the grid export toolbar
- **Cancel long-running queries** — per-connection cancel button (`pg_cancel_backend` and equivalents) instead of waiting or killing the app
- **Settings export / import** — share theme, accent, editor options, page sizes, and defaults across machines (JSON file)
- **Windows/Linux title bar fix** — the macOS overlay drag strip (`h-7` in `App.tsx`) is now gated to macOS; Windows/Linux use the native title bar for dragging
- **SQLite table editor (Create/Edit Table)** — the visual Create Table / Edit Table flow extended to SQLite: SQLite-aware type mapping (`INTEGER PRIMARY KEY AUTOINCREMENT` instead of `serial`, `TEXT`/`REAL`/`BLOB`) and ALTER TABLE limits respected (`ADD COLUMN` can't add PK/UNIQUE, `DROP COLUMN` needs SQLite ≥3.35)
- **Version bump** 0.7.7 → **0.7.8**.
## ✅ Shipped (0.7.7) ## ✅ Shipped (0.7.7)
### Admin follow-up ### Admin follow-up
@@ -151,24 +155,13 @@ Deferred from 0.7.0, slated for this bucket:
- **Input styling sweep** — `rounded-full` → `rounded-lg` on all form controls. - **Input styling sweep** — `rounded-full` → `rounded-lg` on all form controls.
- **Version bump** 0.6.0 → **0.7.0**. - **Version bump** 0.6.0 → **0.7.0**.
**Not in 0.7.0** (deferred, tracked above): 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. **Deferred from 0.7.0**: Redis key browsing and MySQL Objects/ERD views (still in the queue); SSH key management and onboarding tour (planned). Since shipped elsewhere: backup/restore/sync for MySQL & SQLite and settings import/export (0.7.8); multiple result sets (slated for 0.8.0).
## ✅ Shipped ## ✅ Shipped — earlier releases (pre-0.7.0)
- Full PostgreSQL object management — create/edit/drop staged through the changes queue (v0.7.6)
- Enable Keychain toggle — opt-out password persistence (v0.7.6)
- Objects view tabbed workspace (v0.7.6)
- Bundled PG client tools — `pg_dump`/`pg_restore`/`psql` shipped with the app, system-first with bundled fallback (v0.7.5)
- Schema CRUD — create/rename/drop schemas with CASCADE + dependency warning (v0.7.5)
- Global object search — Cmd+K across all object types in the current schema (v0.7.5)
- Copy as DDL for any object (v0.7.5)
- Object dependencies — `pg_depend` view before destructive drops (v0.7.5)
- Tauri 2.0 + React 19 + TypeScript 5.8 project shell - 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 - Connection management with URI parser, SSH tunnels, TLS, OS keychain
- PostgreSQL and SQLite browse/query support
- Workspace tree, folders, tags, favorites, recents - Workspace tree, folders, tags, favorites, recents
- Multi-tab DB viewer with virtualized grid, server-side filtering/sorting - Multi-tab DB viewer with virtualized grid, server-side filtering/sorting
- FK preview, JSON popover, inline cell editing, changes queue - FK preview, JSON popover, inline cell editing, changes queue
@@ -177,4 +170,4 @@ Deferred from 0.7.0, slated for this bucket:
- Full PostgreSQL object explorer + schema visualizer - Full PostgreSQL object explorer + schema visualizer
- Backup, restore, and DB-to-DB sync tools - Backup, restore, and DB-to-DB sync tools
- Settings redesign with theme, accent color, editor options - Settings redesign with theme, accent color, editor options
- Built-in **Gridline Demo (SQLite)** database - Built-in **Gridline Demo (SQLite)** database
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "gridline-desktop", "name": "gridline-desktop",
"private": true, "private": true,
"version": "0.7.14", "version": "0.7.15",
"description": "An open-source, high-performance database GUI client for PostgreSQL and beyond", "description": "An open-source, high-performance database GUI client for PostgreSQL and beyond",
"type": "module", "type": "module",
"scripts": { "scripts": {
+1 -1
View File
@@ -1783,7 +1783,7 @@ dependencies = [
[[package]] [[package]]
name = "gridline" name = "gridline"
version = "0.7.14" version = "0.7.15"
dependencies = [ dependencies = [
"bytes", "bytes",
"chrono", "chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "gridline" name = "gridline"
version = "0.7.14" version = "0.7.15"
description = "An open-source, high-performance database GUI client for PostgreSQL and beyond" description = "An open-source, high-performance database GUI client for PostgreSQL and beyond"
authors = ["you"] authors = ["you"]
edition = "2021" edition = "2021"
@@ -566,6 +566,14 @@ pub fn build_pg_insert_sql(
table: &str, table: &str,
columns: &[(String, serde_json::Value)], columns: &[(String, serde_json::Value)],
) -> (String, Vec<serde_json::Value>) { ) -> (String, Vec<serde_json::Value>) {
// A table with no editable columns (e.g. only a serial/identity PK) inserts
// with all defaults — `INSERT ... () VALUES ()` is invalid SQL.
if columns.is_empty() {
return (
format!("INSERT INTO \"{}\".\"{}\" DEFAULT VALUES", schema, table),
vec![],
);
}
let cols: Vec<String> = columns.iter().map(|(c, _)| format!("\"{}\"", c)).collect(); let cols: Vec<String> = columns.iter().map(|(c, _)| format!("\"{}\"", c)).collect();
let mut params: Vec<serde_json::Value> = Vec::new(); let mut params: Vec<serde_json::Value> = Vec::new();
let placeholders: Vec<String> = columns let placeholders: Vec<String> = columns
@@ -3102,6 +3110,15 @@ mod tests {
); );
} }
/// A table with no editable columns (only a serial/identity PK) must insert
/// with `DEFAULT VALUES`, not the invalid `INSERT ... () VALUES ()`.
#[test]
fn build_pg_insert_sql_empty_columns_uses_default_values() {
let (sql, params) = build_pg_insert_sql("public", "t", &[]);
assert_eq!(sql, "INSERT INTO \"public\".\"t\" DEFAULT VALUES");
assert!(params.is_empty());
}
/// Verify that `get_sqlite_ddl` returns the stored CREATE TABLE statement /// Verify that `get_sqlite_ddl` returns the stored CREATE TABLE statement
/// from `sqlite_master`. /// from `sqlite_master`.
#[test] #[test]
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Gridline", "productName": "Gridline",
"version": "0.7.14", "version": "0.7.15",
"identifier": "com.adrianbonpin.gridline", "identifier": "com.adrianbonpin.gridline",
"build": { "build": {
"beforeDevCommand": "bun run dev", "beforeDevCommand": "bun run dev",
@@ -231,6 +231,7 @@ export function DbViewerScreen({
const changesQueue = useDbViewerStore((s) => s.changesQueue); const changesQueue = useDbViewerStore((s) => s.changesQueue);
const tables = useDbViewerStore((s) => s.tables); const tables = useDbViewerStore((s) => s.tables);
const stageCellEdit = useDbViewerStore((s) => s.stageCellEdit); const stageCellEdit = useDbViewerStore((s) => s.stageCellEdit);
const updateChange = useDbViewerStore((s) => s.updateChange);
const isRunning = activeTab?.tabType === "query" && !!activeTab?.loading; const isRunning = activeTab?.tabType === "query" && !!activeTab?.loading;
@@ -339,6 +340,19 @@ export function DbViewerScreen({
[activeTab, stageCellEdit], [activeTab, stageCellEdit],
); );
const handleStageInsertCell = useCallback(
(changeId: string, column: string, value: string | null) => {
const change = useDbViewerStore
.getState()
.changesQueue.find((c) => c.id === changeId);
if (!change) return;
const newData = { ...(change.newData ?? {}) };
newData[column] = value;
updateChange(changeId, { newData });
},
[updateChange],
);
const handleOpenRowDetail = useCallback((rowIndex: number) => { const handleOpenRowDetail = useCallback((rowIndex: number) => {
setRowDetailIdx(rowIndex); setRowDetailIdx(rowIndex);
}, []); }, []);
@@ -832,6 +846,24 @@ export function DbViewerScreen({
// Data is already filtered and sorted server-side; no client-side transform needed. // Data is already filtered and sorted server-side; no client-side transform needed.
const processedRows = rawRows; const processedRows = rawRows;
// Pending insert rows (from the changes queue) are prepended to the grid as
// editable rows. Only pending inserts for the active table tab count.
const pendingInserts =
activeTab?.tabType === "table"
? changesQueue.filter(
(c) =>
c.type === "insert" &&
c.status === "pending" &&
c.schema === activeTab.schema &&
c.table === activeTab.table,
)
: [];
const pendingInsertChangeIds = pendingInserts.map((c) => c.id);
const pendingInsertRows = pendingInserts.map((c) =>
columns.map((col) => c.newData?.[col.name] ?? null),
);
const displayRows = [...pendingInsertRows, ...processedRows];
const onPanelResizeStart = useCallback( const onPanelResizeStart = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
@@ -1184,7 +1216,7 @@ const onQueriesPanelResizeStart = useCallback(
connectionId={connectionId} connectionId={connectionId}
schema={activeSchema} schema={activeSchema}
table={activeTable} table={activeTable}
rows={processedRows} rows={displayRows}
columns={columns} columns={columns}
hiddenColumns={hiddenColumns} hiddenColumns={hiddenColumns}
selectedRows={selectedRows} selectedRows={selectedRows}
@@ -1192,6 +1224,7 @@ const onQueriesPanelResizeStart = useCallback(
tabType={activeTab?.tabType ?? "table"} tabType={activeTab?.tabType ?? "table"}
getLocator={getLocator} getLocator={getLocator}
onStageEdit={readOnlyTable ? undefined : handleStageEdit} onStageEdit={readOnlyTable ? undefined : handleStageEdit}
onStageInsertCell={readOnlyTable ? undefined : handleStageInsertCell}
onOpenRowDetail={handleOpenRowDetail} onOpenRowDetail={handleOpenRowDetail}
readOnly={readOnlyTable} readOnly={readOnlyTable}
enumValues={editorOptions?.enums} enumValues={editorOptions?.enums}
@@ -1199,6 +1232,7 @@ const onQueriesPanelResizeStart = useCallback(
fkPlaceholders={editorOptions?.fkPlaceholders} fkPlaceholders={editorOptions?.fkPlaceholders}
stagedValues={stagedValues} stagedValues={stagedValues}
pendingKeys={pendingKeys} pendingKeys={pendingKeys}
pendingInsertChangeIds={pendingInsertChangeIds}
onToggleRow={(rowIndex) => { onToggleRow={(rowIndex) => {
setSelectedRows( setSelectedRows(
(prev) => { (prev) => {
@@ -1348,7 +1382,7 @@ const onQueriesPanelResizeStart = useCallback(
connectionId={connectionId} connectionId={connectionId}
schema={activeSchema} schema={activeSchema}
table={activeTable} table={activeTable}
rows={processedRows} rows={displayRows}
columns={columns} columns={columns}
hiddenColumns={hiddenColumns} hiddenColumns={hiddenColumns}
selectedRows={selectedRows} selectedRows={selectedRows}
@@ -1356,6 +1390,7 @@ const onQueriesPanelResizeStart = useCallback(
tabType={activeTab?.tabType ?? "table"} tabType={activeTab?.tabType ?? "table"}
getLocator={getLocator} getLocator={getLocator}
onStageEdit={readOnlyTable ? undefined : handleStageEdit} onStageEdit={readOnlyTable ? undefined : handleStageEdit}
onStageInsertCell={readOnlyTable ? undefined : handleStageInsertCell}
onOpenRowDetail={handleOpenRowDetail} onOpenRowDetail={handleOpenRowDetail}
readOnly={readOnlyTable} readOnly={readOnlyTable}
enumValues={editorOptions?.enums} enumValues={editorOptions?.enums}
@@ -1363,6 +1398,7 @@ const onQueriesPanelResizeStart = useCallback(
fkPlaceholders={editorOptions?.fkPlaceholders} fkPlaceholders={editorOptions?.fkPlaceholders}
stagedValues={stagedValues} stagedValues={stagedValues}
pendingKeys={pendingKeys} pendingKeys={pendingKeys}
pendingInsertChangeIds={pendingInsertChangeIds}
onToggleRow={(rowIndex) => { onToggleRow={(rowIndex) => {
setSelectedRows((prev) => { setSelectedRows((prev) => {
const next = new Set( const next = new Set(
@@ -1,27 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { FilterBuilder } from "./FilterBuilder";
import type { ColumnInfo } from "../../lib/types";
const cols: ColumnInfo[] = [
{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: false, is_generated: false },
{ name: "name", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
];
describe("FilterBuilder", () => {
it("drops a column chip into the drop zone to create a rule with a type-aware operator", () => {
const onChange = vi.fn();
render(<FilterBuilder columns={cols} rules={[]} onChange={onChange} />);
fireEvent.click(screen.getByText("name"));
expect(onChange).toHaveBeenCalledWith([
expect.objectContaining({ column: "name", operator: "contains" }),
]);
});
it("removing a rule calls onChange without it", () => {
const onChange = vi.fn();
const rules = [{ id: "r1", column: "name", operator: "contains" as const, value: "Al" }];
render(<FilterBuilder columns={cols} rules={rules} onChange={onChange} />);
fireEvent.click(screen.getByLabelText("Remove filter name"));
expect(onChange).toHaveBeenCalledWith([]);
});
});
@@ -1,132 +0,0 @@
import { useState } from "react";
import { DndContext, useDraggable, useDroppable } from "@dnd-kit/core";
import { X } from "lucide-react";
import { defaultFilterOperator } from "../grid/gridEditability";
import type { ColumnInfo } from "../../lib/types";
import type { FilterRule, FilterOperator } from "../../stores/dbViewerStore";
interface Props {
columns: ColumnInfo[];
rules: FilterRule[];
onChange: (rules: FilterRule[]) => void;
}
function Chip({ col, onAdd }: { col: ColumnInfo; onAdd: () => void }) {
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
id: `col-${col.name}`,
data: { column: col },
});
return (
<button
ref={setNodeRef}
{...attributes}
{...listeners}
onClick={onAdd}
className={`px-2 py-1 text-xs rounded border border-border bg-surface text-text hover:border-accent ${
isDragging ? "opacity-50" : ""
}`}
>
{col.name}
</button>
);
}
const OPERATORS: FilterOperator[] = [
"eq",
"neq",
"contains",
"starts",
"ends",
"gt",
"lt",
"null",
"notnull",
];
export function FilterBuilder({ columns, rules, onChange }: Props) {
const [val, setVal] = useState<Record<string, string>>({});
const addRule = (col: ColumnInfo) => {
const op = defaultFilterOperator(col.data_type);
onChange([
...rules,
{
id: `f-${Date.now()}-${col.name}`,
column: col.name,
operator: op,
value: "",
},
]);
};
const { setNodeRef, isOver } = useDroppable({ id: "filter-dropzone" });
const remove = (id: string) => onChange(rules.filter((r) => r.id !== id));
const update = (id: string, patch: Partial<FilterRule>) =>
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
return (
<DndContext
onDragEnd={(e) => {
const id = e.active.id as string;
const colName = id.replace(/^col-/, "");
const col = columns.find((c) => c.name === colName);
if (col && e.over?.id === "filter-dropzone") addRule(col);
}}
>
<div className="flex flex-wrap gap-1 mb-2">
{columns.map((c) => (
<Chip key={c.name} col={c} onAdd={() => addRule(c)} />
))}
</div>
<div
ref={setNodeRef}
className={`min-h-[40px] border border-dashed rounded p-2 space-y-1 ${
isOver ? "border-accent bg-surface" : "border-border"
}`}
>
{rules.length === 0 && (
<span className="text-xs text-text-muted">
Drop columns here to add filters
</span>
)}
{rules.map((r) => (
<div key={r.id} className="flex items-center gap-2 text-xs">
<span className="text-text font-semibold">{r.column}</span>
<select
value={r.operator}
onChange={(e) =>
update(r.id, { operator: e.target.value as FilterOperator })
}
className="bg-surface border border-border rounded px-1"
>
{OPERATORS.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
{!["null", "notnull"].includes(r.operator) && (
<input
value={val[r.id] ?? r.value}
onChange={(e) => {
setVal({ ...val, [r.id]: e.target.value });
update(r.id, { value: e.target.value });
}}
className="bg-surface border border-border rounded px-1 flex-1"
placeholder="value"
/>
)}
<button
aria-label={`Remove filter ${r.column}`}
onClick={() => remove(r.id)}
className="text-text-muted hover:text-red-400"
>
<X size={12} />
</button>
</div>
))}
</div>
</DndContext>
);
}
@@ -99,7 +99,7 @@ export function SchemaMenu({ connectionId, schema, onRefresh }: SchemaMenuProps)
"w-full bg-surface border border-border rounded-md px-3 py-2 text-sm text-text placeholder:text-text-muted outline-none focus:border-accent/50 transition-colors"; "w-full bg-surface border border-border rounded-md px-3 py-2 text-sm text-text placeholder:text-text-muted outline-none focus:border-accent/50 transition-colors";
return ( return (
<div className="flex items-center gap-1" ref={menuRef}> <div className="relative flex items-center gap-1" ref={menuRef}>
<button <button
aria-label="New schema" aria-label="New schema"
onClick={() => { onClick={() => {
@@ -123,7 +123,7 @@ export function SchemaMenu({ connectionId, schema, onRefresh }: SchemaMenuProps)
)} )}
{menuOpen && ( {menuOpen && (
<div className="absolute right-0 top-8 mt-1 rounded-xl bg-surface border border-border py-1 z-20 min-w-[160px] shadow-lg"> <div className="absolute left-0 top-8 mt-1 rounded-xl bg-surface border border-border py-1 z-20 min-w-[160px] shadow-lg">
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -325,13 +325,115 @@ describe("TableControls", () => {
expect(screen.queryByLabelText(/insert row/i)).toBeNull(); expect(screen.queryByLabelText(/insert row/i)).toBeNull();
}); });
it("renders the FilterBuilder inside the filter popover", () => { it("stages an insert with only editable columns when Insert Row is clicked", () => {
const cols = [
{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: "nextval('users_id_seq')", editable: false, is_generated: false },
{ name: "name", data_type: "text", is_nullable: false, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
{ name: "created_at", data_type: "timestamp", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: "now()", editable: true, is_generated: false },
];
seed([makeTab({ data: { columns: cols, rows: [], total_rows: 0, page: 1, page_size: 50 } })], "tab-1");
renderControls({ columns: cols, rows: [] });
fireEvent.click(screen.getByLabelText(/insert row/i));
const queue = useDbViewerStore.getState().changesQueue;
expect(queue).toHaveLength(1);
expect(queue[0].type).toBe("insert");
expect(queue[0].newData).toEqual({ name: null, created_at: null });
});
it("renders the column filter popover with the + Add filter action", () => {
seed([makeTab()], "tab-1"); seed([makeTab()], "tab-1");
renderControls(); renderControls();
fireEvent.click(screen.getByLabelText(/column filters/i)); fireEvent.click(screen.getByLabelText(/column filters/i));
expect( expect(screen.getByText("Column Filters")).toBeInTheDocument();
screen.getByText("Drop columns here to add filters"), expect(screen.getByText("+ Add filter")).toBeInTheDocument();
).toBeInTheDocument(); });
it("debounces filter value edits so a query only fires after the user stops typing", () => {
vi.useFakeTimers();
seed([makeTab()], "tab-1");
const onFilterChange = vi.fn();
renderControls({
onFilterChange,
filterRules: [
{ id: "r1", column: "id", operator: "contains", value: "" },
],
});
fireEvent.click(screen.getByLabelText(/column filters/i));
const input = screen.getByPlaceholderText("value");
fireEvent.change(input, { target: { value: "a" } });
fireEvent.change(input, { target: { value: "ab" } });
fireEvent.change(input, { target: { value: "abc" } });
// No commit yet — the debounce hasn't elapsed.
expect(onFilterChange).not.toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(400);
});
// Only the final value is committed, once.
expect(onFilterChange).toHaveBeenCalledTimes(1);
expect(onFilterChange).toHaveBeenCalledWith([
expect.objectContaining({ value: "abc" }),
]);
});
it("closes the filter popover when the button is clicked while it is open", () => {
seed([makeTab()], "tab-1");
renderControls();
fireEvent.click(screen.getByLabelText(/column filters/i));
expect(screen.getByText("Column Filters")).toBeInTheDocument();
// Clicking the button while open closes it (plain toggle).
fireEvent.click(screen.getByLabelText(/column filters/i));
expect(screen.queryByText("Column Filters")).not.toBeInTheDocument();
});
it("toggles the sort rules popover open and closed", () => {
seed([makeTab()], "tab-1");
renderControls();
fireEvent.click(screen.getByLabelText(/sort rules/i));
expect(screen.getByText("Sort Rules")).toBeInTheDocument();
fireEvent.click(screen.getByLabelText(/sort rules/i));
expect(screen.queryByText("Sort Rules")).not.toBeInTheDocument();
});
it("toggles the auto-refresh dropdown open and closed", () => {
seed([makeTab()], "tab-1");
renderControls();
fireEvent.click(screen.getByLabelText(/auto-refresh/i));
expect(screen.getByText("Off")).toBeInTheDocument();
fireEvent.click(screen.getByLabelText(/auto-refresh/i));
expect(screen.queryByText("Off")).not.toBeInTheDocument();
});
it("toggles the show/hide columns dropdown open and closed", () => {
seed([makeTab()], "tab-1");
renderControls();
fireEvent.click(screen.getByLabelText(/toggle columns/i));
expect(screen.getByText("Visible columns")).toBeInTheDocument();
fireEvent.click(screen.getByLabelText(/toggle columns/i));
expect(screen.queryByText("Visible columns")).not.toBeInTheDocument();
});
it("toggles the export dropdown open and closed", () => {
seed([makeTab()], "tab-1");
renderControls();
fireEvent.click(screen.getByLabelText(/export/i));
expect(screen.getByText("JSON")).toBeInTheDocument();
fireEvent.click(screen.getByLabelText(/export/i));
expect(screen.queryByText("JSON")).not.toBeInTheDocument();
}); });
it("export dropdown includes the Excel option", () => { it("export dropdown includes the Excel option", () => {
@@ -6,7 +6,6 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useDbViewerStore, type FilterRule, type SortRule } from "../../stores/dbViewerStore"; import { useDbViewerStore, type FilterRule, type SortRule } from "../../stores/dbViewerStore";
import { useNotificationStore } from "../../stores/notificationStore"; import { useNotificationStore } from "../../stores/notificationStore";
import { FilterBuilder } from "./FilterBuilder";
import { Tooltip } from "../ui/Tooltip"; import { Tooltip } from "../ui/Tooltip";
import { exportData } from "../../lib/exportData"; import { exportData } from "../../lib/exportData";
import type { ColumnInfo } from "../../lib/types"; import type { ColumnInfo } from "../../lib/types";
@@ -49,22 +48,28 @@ function DropdownMenu({
open, open,
setOpen, setOpen,
align, align,
wrapRef,
children, children,
}: { }: {
open: boolean; open: boolean;
setOpen: (v: boolean) => void; setOpen: (v: boolean) => void;
align?: "left" | "right"; align?: "left" | "right";
/** When provided, outside-click detection uses this wrapper (which should
* contain both the toggle button and the menu) so the button never fights
* the outside-click handler. */
wrapRef?: React.RefObject<HTMLDivElement | null>;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const close = (e: MouseEvent) => { const close = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); const container = wrapRef?.current ?? ref.current;
if (container && !container.contains(e.target as Node)) setOpen(false);
}; };
document.addEventListener("mousedown", close); document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close); return () => document.removeEventListener("mousedown", close);
}, [open, setOpen]); }, [open, setOpen, wrapRef]);
if (!open) return null; if (!open) return null;
return ( return (
@@ -79,28 +84,84 @@ function DropdownMenu({
); );
} }
/**
* Debounced free-text input for filter values. The value is committed to the
* rules (and thus triggers a server-side refetch) only after the user stops
* typing for `DEBOUNCE_MS`. Without this, every keystroke fires a query and
* the in-flight guard in `fetchData` silently drops intermediate fetches, so
* the final filter value can end up not being applied.
*/
const FILTER_DEBOUNCE_MS = 400;
function DebouncedValueInput({
value,
onCommit,
placeholder,
}: {
value: string;
onCommit: (v: string) => void;
placeholder?: string;
}) {
const [draft, setDraft] = useState(value);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Keep the draft in sync when the committed value changes externally
// (e.g. a rule is replaced or removed while this input is mounted).
useEffect(() => {
setDraft(value);
}, [value]);
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = e.target.value;
setDraft(v);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => onCommit(v), FILTER_DEBOUNCE_MS);
};
return (
<input
type="text"
value={draft}
onChange={handleChange}
placeholder={placeholder}
className="flex-1 rounded border border-border bg-surface text-xs px-1.5 py-1 text-text min-w-0"
/>
);
}
function FilterModal({ function FilterModal({
columns, columns,
rules, rules,
onChange, onChange,
open, open,
setOpen, setOpen,
wrapRef,
}: { }: {
columns: ColumnInfo[]; columns: ColumnInfo[];
rules: FilterRule[]; rules: FilterRule[];
onChange: (rules: FilterRule[]) => void; onChange: (rules: FilterRule[]) => void;
open: boolean; open: boolean;
setOpen: (v: boolean) => void; setOpen: (v: boolean) => void;
wrapRef: React.RefObject<HTMLDivElement | null>;
}) { }) {
const ref = useRef<HTMLDivElement>(null); // Close on outside click, but treat the toggle button (which lives inside
// the same wrapper) as part of the modal so it never fights the toggle.
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const close = (e: MouseEvent) => { const close = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
setOpen(false);
}
}; };
document.addEventListener("mousedown", close); document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close); return () => document.removeEventListener("mousedown", close);
}, [open, setOpen]); }, [open, setOpen, wrapRef]);
if (!open) return null; if (!open) return null;
@@ -116,17 +177,13 @@ function FilterModal({
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r))); onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
return ( return (
<div <div className="absolute top-full left-0 mt-1 z-30 w-96 rounded-lg bg-surface border border-border shadow-lg p-3">
ref={ref}
className="absolute top-full left-0 mt-1 z-30 w-96 rounded-lg bg-surface border border-border shadow-lg p-3"
>
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-text">Column Filters</span> <span className="text-xs font-semibold text-text">Column Filters</span>
<button type="button" onClick={() => setOpen(false)} className="text-text-muted hover:text-text cursor-pointer"> <button type="button" onClick={() => setOpen(false)} className="text-text-muted hover:text-text cursor-pointer">
<X size={14} /> <X size={14} />
</button> </button>
</div> </div>
<FilterBuilder columns={columns} rules={rules} onChange={onChange} />
{rules.map((rule) => ( {rules.map((rule) => (
<div key={rule.id} className="flex items-center gap-1.5 mb-1.5"> <div key={rule.id} className="flex items-center gap-1.5 mb-1.5">
<select <select
@@ -152,12 +209,10 @@ function FilterModal({
<option value="notnull">not null</option> <option value="notnull">not null</option>
</select> </select>
{rule.operator !== "null" && rule.operator !== "notnull" && ( {rule.operator !== "null" && rule.operator !== "notnull" && (
<input <DebouncedValueInput
type="text"
value={rule.value} value={rule.value}
onChange={(e) => updateRule(rule.id, { value: e.target.value })} onCommit={(v) => updateRule(rule.id, { value: v })}
placeholder="value" placeholder="value"
className="flex-1 rounded border border-border bg-surface text-xs px-1.5 py-1 text-text min-w-0"
/> />
)} )}
<button type="button" onClick={() => removeRule(rule.id)} className="text-text-muted hover:text-red-400 shrink-0 cursor-pointer"> <button type="button" onClick={() => removeRule(rule.id)} className="text-text-muted hover:text-red-400 shrink-0 cursor-pointer">
@@ -182,22 +237,27 @@ function SortModal({
onChange, onChange,
open, open,
setOpen, setOpen,
wrapRef,
}: { }: {
columns: ColumnInfo[]; columns: ColumnInfo[];
rules: SortRule[]; rules: SortRule[];
onChange: (rules: SortRule[]) => void; onChange: (rules: SortRule[]) => void;
open: boolean; open: boolean;
setOpen: (v: boolean) => void; setOpen: (v: boolean) => void;
wrapRef: React.RefObject<HTMLDivElement | null>;
}) { }) {
const ref = useRef<HTMLDivElement>(null); // Close on outside click, but treat the toggle button (which lives inside
// the same wrapper) as part of the modal so it never fights the toggle.
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const close = (e: MouseEvent) => { const close = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
setOpen(false);
}
}; };
document.addEventListener("mousedown", close); document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close); return () => document.removeEventListener("mousedown", close);
}, [open, setOpen]); }, [open, setOpen, wrapRef]);
if (!open) return null; if (!open) return null;
@@ -213,10 +273,7 @@ function SortModal({
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r))); onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
return ( return (
<div <div className="absolute top-full left-0 mt-1 z-30 w-72 rounded-lg bg-surface border border-border shadow-lg p-3">
ref={ref}
className="absolute top-full left-0 mt-1 z-30 w-72 rounded-lg bg-surface border border-border shadow-lg p-3"
>
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-text">Sort Rules</span> <span className="text-xs font-semibold text-text">Sort Rules</span>
<button type="button" onClick={() => setOpen(false)} className="text-text-muted hover:text-text cursor-pointer"> <button type="button" onClick={() => setOpen(false)} className="text-text-muted hover:text-text cursor-pointer">
@@ -272,6 +329,7 @@ function BulkActionsDropdown({
onClearSelection: () => void; onClearSelection: () => void;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
const addChange = useDbViewerStore((s) => s.addChange); const addChange = useDbViewerStore((s) => s.addChange);
const copyToClipboard = (text: string) => { const copyToClipboard = (text: string) => {
@@ -338,7 +396,7 @@ function BulkActionsDropdown({
}; };
return ( return (
<div className="relative"> <div className="relative" ref={wrapRef}>
<button <button
type="button" type="button"
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
@@ -347,7 +405,7 @@ function BulkActionsDropdown({
<span className="text-xs font-medium">Actions</span> <span className="text-xs font-medium">Actions</span>
<ChevronDown size={12} /> <ChevronDown size={12} />
</button> </button>
<DropdownMenu open={open} setOpen={setOpen} align="right"> <DropdownMenu open={open} setOpen={setOpen} align="right" wrapRef={wrapRef}>
<button <button
type="button" type="button"
onClick={handleCopyJSON} onClick={handleCopyJSON}
@@ -445,11 +503,16 @@ export function TableControls({
// local state // local state
const [filterOpen, setFilterOpen] = useState(false); const [filterOpen, setFilterOpen] = useState(false);
const filterWrapRef = useRef<HTMLDivElement>(null);
const [sortOpen, setSortOpen] = useState(false); const [sortOpen, setSortOpen] = useState(false);
const sortWrapRef = useRef<HTMLDivElement>(null);
const [columnMenuOpen, setColumnMenuOpen] = useState(false); const [columnMenuOpen, setColumnMenuOpen] = useState(false);
const columnMenuWrapRef = useRef<HTMLDivElement>(null);
const [exportOpen, setExportOpen] = useState(false); const [exportOpen, setExportOpen] = useState(false);
const exportWrapRef = useRef<HTMLDivElement>(null);
const [autoRefresh, setAutoRefresh] = useState(defaultRefreshRate); const [autoRefresh, setAutoRefresh] = useState(defaultRefreshRate);
const [autoRefreshOpen, setAutoRefreshOpen] = useState(false); const [autoRefreshOpen, setAutoRefreshOpen] = useState(false);
const autoRefreshWrapRef = useRef<HTMLDivElement>(null);
// Auto-refresh: a self-restarting timer that only counts down while the tab // Auto-refresh: a self-restarting timer that only counts down while the tab
// is idle. Fires a refresh, waits for it to complete (loading → false), // is idle. Fires a refresh, waits for it to complete (loading → false),
@@ -484,8 +547,14 @@ export function TableControls({
}; };
const handleInsertRow = () => { const handleInsertRow = () => {
// Build the insert template from editable columns only. PK / generated /
// identity columns are omitted so their defaults (serial, identity,
// generated) apply on commit — an explicit NULL would bypass them and
// fail with a NOT NULL violation.
const newData: Record<string, unknown> = {}; const newData: Record<string, unknown> = {};
columns.forEach((c) => { newData[c.name] = null; }); columns.forEach((c) => {
if (c.editable && !c.is_pk && !c.is_generated) newData[c.name] = null;
});
addChange({ addChange({
type: "insert", type: "insert",
schema, schema,
@@ -545,7 +614,7 @@ export function TableControls({
); );
const exportControl = ( const exportControl = (
<div className="relative"> <div className="relative" ref={exportWrapRef}>
<Tooltip content="Export" side="bottom"> <Tooltip content="Export" side="bottom">
<button <button
type="button" type="button"
@@ -556,7 +625,7 @@ export function TableControls({
<Download size={14} /> <Download size={14} />
</button> </button>
</Tooltip> </Tooltip>
<DropdownMenu open={exportOpen} setOpen={setExportOpen}> <DropdownMenu open={exportOpen} setOpen={setExportOpen} wrapRef={exportWrapRef}>
{EXPORT_FORMATS.map((fmt) => ( {EXPORT_FORMATS.map((fmt) => (
<button <button
key={fmt.ext} key={fmt.ext}
@@ -572,7 +641,7 @@ export function TableControls({
); );
const columnsControl = ( const columnsControl = (
<div className="relative"> <div className="relative" ref={columnMenuWrapRef}>
<Tooltip content="Show/hide columns" side="bottom"> <Tooltip content="Show/hide columns" side="bottom">
<button <button
type="button" type="button"
@@ -587,6 +656,7 @@ export function TableControls({
open={columnMenuOpen} open={columnMenuOpen}
setOpen={setColumnMenuOpen} setOpen={setColumnMenuOpen}
align={isQuery ? "left" : "right"} align={isQuery ? "left" : "right"}
wrapRef={columnMenuWrapRef}
> >
<div className="px-2 py-1 text-[10px] text-text-muted uppercase tracking-wider"> <div className="px-2 py-1 text-[10px] text-text-muted uppercase tracking-wider">
Visible columns Visible columns
@@ -658,7 +728,7 @@ export function TableControls({
{refreshControl} {refreshControl}
{/* Auto-refresh */} {/* Auto-refresh */}
<div className="relative"> <div className="relative" ref={autoRefreshWrapRef}>
<Tooltip content={`Auto-refresh: ${autoRefresh > 0 ? `${autoRefresh / 1000}s` : "Off"}`} side="bottom"> <Tooltip content={`Auto-refresh: ${autoRefresh > 0 ? `${autoRefresh / 1000}s` : "Off"}`} side="bottom">
<button <button
type="button" type="button"
@@ -672,7 +742,7 @@ export function TableControls({
{autoRefresh > 0 && <span className="text-[10px] font-medium">{autoRefresh / 1000}s</span>} {autoRefresh > 0 && <span className="text-[10px] font-medium">{autoRefresh / 1000}s</span>}
</button> </button>
</Tooltip> </Tooltip>
<DropdownMenu open={autoRefreshOpen} setOpen={setAutoRefreshOpen}> <DropdownMenu open={autoRefreshOpen} setOpen={setAutoRefreshOpen} wrapRef={autoRefreshWrapRef}>
{AUTO_REFRESH_OPTIONS.map((opt) => ( {AUTO_REFRESH_OPTIONS.map((opt) => (
<button <button
key={opt.value} key={opt.value}
@@ -692,7 +762,7 @@ export function TableControls({
<div className="w-px h-4 bg-border mx-1" /> <div className="w-px h-4 bg-border mx-1" />
{/* Filter */} {/* Filter */}
<div className="relative"> <div className="relative" ref={filterWrapRef}>
<Tooltip content="Column filters" side="bottom"> <Tooltip content="Column filters" side="bottom">
<button <button
type="button" type="button"
@@ -716,11 +786,12 @@ export function TableControls({
onChange={onFilterChange} onChange={onFilterChange}
open={filterOpen} open={filterOpen}
setOpen={setFilterOpen} setOpen={setFilterOpen}
wrapRef={filterWrapRef}
/> />
</div> </div>
{/* Sort */} {/* Sort */}
<div className="relative"> <div className="relative" ref={sortWrapRef}>
<Tooltip content="Sort rules" side="bottom"> <Tooltip content="Sort rules" side="bottom">
<button <button
type="button" type="button"
@@ -744,6 +815,7 @@ export function TableControls({
onChange={onSortChange} onChange={onSortChange}
open={sortOpen} open={sortOpen}
setOpen={setSortOpen} setOpen={setSortOpen}
wrapRef={sortWrapRef}
/> />
</div> </div>
@@ -6,16 +6,18 @@ interface Props {
isJson: boolean; isJson: boolean;
isFk: boolean; isFk: boolean;
nullable: boolean; nullable: boolean;
/** When true, hide row-scoped actions (View Row / Select Row) — pending insert rows have no backing row. */
isInsertRow?: boolean;
onCopy: () => void; onCopyJson: () => void; onEdit: () => void; onCopy: () => void; onCopyJson: () => void; onEdit: () => void;
onSetNull: () => void; onOpenFk: () => void; onClose: () => void; onSetNull: () => void; onOpenFk: () => void; onClose: () => void;
onViewRow: () => void; onSelectRow: () => void; onViewRow: () => void; onSelectRow: () => void;
} }
export function CellContextMenu({ anchorRect, editable, isJson, isFk, nullable, onCopy, onCopyJson, onEdit, onSetNull, onOpenFk, onClose, onViewRow, onSelectRow }: Props) { export function CellContextMenu({ anchorRect, editable, isJson, isFk, nullable, isInsertRow = false, onCopy, onCopyJson, onEdit, onSetNull, onOpenFk, onClose, onViewRow, onSelectRow }: Props) {
const items: { label: string; icon: React.ReactNode; onClick: () => void; show: boolean }[] = [ const items: { label: string; icon: React.ReactNode; onClick: () => void; show: boolean }[] = [
{ label: "Copy", icon: <Copy size={12} />, onClick: () => { onCopy(); onClose(); }, show: true }, { label: "Copy", icon: <Copy size={12} />, onClick: () => { onCopy(); onClose(); }, show: true },
{ label: "Copy JSON", icon: <Braces size={12} />, onClick: () => { onCopyJson(); onClose(); }, show: isJson }, { label: "Copy JSON", icon: <Braces size={12} />, onClick: () => { onCopyJson(); onClose(); }, show: isJson },
{ label: "View Row", icon: <Eye size={12} />, onClick: () => { onViewRow(); onClose(); }, show: true }, { label: "View Row", icon: <Eye size={12} />, onClick: () => { onViewRow(); onClose(); }, show: !isInsertRow },
{ label: "Select Row", icon: <MousePointerClick size={12} />, onClick: () => { onSelectRow(); onClose(); }, show: true }, { label: "Select Row", icon: <MousePointerClick size={12} />, onClick: () => { onSelectRow(); onClose(); }, show: !isInsertRow },
{ label: "Edit", icon: <Pencil size={12} />, onClick: () => { onEdit(); onClose(); }, show: editable }, { label: "Edit", icon: <Pencil size={12} />, onClick: () => { onEdit(); onClose(); }, show: editable },
{ label: "Set NULL", icon: <CircleSlash size={12} />, onClick: () => { onSetNull(); onClose(); }, show: editable && nullable }, { label: "Set NULL", icon: <CircleSlash size={12} />, onClick: () => { onSetNull(); onClose(); }, show: editable && nullable },
{ label: "Open FK reference", icon: <Link2 size={12} />, onClick: () => { onOpenFk(); onClose(); }, show: isFk }, { label: "Open FK reference", icon: <Link2 size={12} />, onClick: () => { onOpenFk(); onClose(); }, show: isFk },
@@ -13,6 +13,42 @@ describe("CellEditor", () => {
fireEvent.keyDown(input, { key: "Enter" }); fireEvent.keyDown(input, { key: "Enter" });
expect(onCommit).toHaveBeenCalledWith("Alicia"); expect(onCommit).toHaveBeenCalledWith("Alicia");
}); });
it("renders a native date input for date columns", () => {
render(<CellEditor initialValue="2024-01-15" dataType="date" onCommit={vi.fn()} onCancel={vi.fn()} />);
const input = screen.getByDisplayValue("2024-01-15");
expect(input).toHaveAttribute("type", "date");
});
it("renders a datetime-local input for timestamp columns", () => {
render(<CellEditor initialValue="2024-01-15 10:30:00" dataType="timestamp without time zone" onCommit={vi.fn()} onCancel={vi.fn()} />);
const input = screen.getByDisplayValue("2024-01-15T10:30");
expect(input).toHaveAttribute("type", "datetime-local");
});
it("commits a datetime-local value in canonical DB format (space separator + seconds)", () => {
const onCommit = vi.fn();
render(<CellEditor initialValue="2024-01-15 10:30:00" dataType="timestamp without time zone" onCommit={onCommit} onCancel={vi.fn()} />);
const input = screen.getByDisplayValue("2024-01-15T10:30");
fireEvent.change(input, { target: { value: "2024-01-15T11:45" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onCommit).toHaveBeenCalledWith("2024-01-15 11:45:00");
});
it("renders a boolean select with true/false options", () => {
render(<CellEditor initialValue="true" dataType="boolean" onCommit={vi.fn()} onCancel={vi.fn()} />);
const select = screen.getByRole("combobox");
expect(select).toHaveValue("true");
expect(screen.getByRole("option", { name: "true" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "false" })).toBeInTheDocument();
});
it("renders a number input for integer columns", () => {
render(<CellEditor initialValue="42" dataType="integer" onCommit={vi.fn()} onCancel={vi.fn()} />);
const input = screen.getByRole("spinbutton");
expect(input).toHaveAttribute("type", "number");
expect(input).toHaveAttribute("step", "1");
});
it("commits null when a nullable cell is emptied", () => { it("commits null when a nullable cell is emptied", () => {
const onCommit = vi.fn(); const onCommit = vi.fn();
render(<CellEditor initialValue="Alice" dataType="text" onCommit={onCommit} onCancel={vi.fn()} nullable />); render(<CellEditor initialValue="Alice" dataType="text" onCommit={onCommit} onCancel={vi.fn()} nullable />);
@@ -40,7 +76,7 @@ describe("CellEditor", () => {
it("blocks empty commits on non-nullable non-text columns and shows an error", () => { it("blocks empty commits on non-nullable non-text columns and shows an error", () => {
const onCommit = vi.fn(); const onCommit = vi.fn();
render(<CellEditor initialValue="42" dataType="integer" onCommit={onCommit} onCancel={vi.fn()} />); render(<CellEditor initialValue="42" dataType="integer" onCommit={onCommit} onCancel={vi.fn()} />);
const input = screen.getByRole("textbox"); const input = screen.getByRole("spinbutton");
fireEvent.change(input, { target: { value: "" } }); fireEvent.change(input, { target: { value: "" } });
fireEvent.keyDown(input, { key: "Enter" }); fireEvent.keyDown(input, { key: "Enter" });
expect(onCommit).not.toHaveBeenCalled(); expect(onCommit).not.toHaveBeenCalled();
@@ -57,7 +93,7 @@ describe("CellEditor", () => {
it("clears the validation error as soon as the user types again", () => { it("clears the validation error as soon as the user types again", () => {
const onCommit = vi.fn(); const onCommit = vi.fn();
render(<CellEditor initialValue="42" dataType="integer" onCommit={onCommit} onCancel={vi.fn()} />); render(<CellEditor initialValue="42" dataType="integer" onCommit={onCommit} onCancel={vi.fn()} />);
const input = screen.getByRole("textbox"); const input = screen.getByRole("spinbutton");
fireEvent.change(input, { target: { value: "" } }); fireEvent.change(input, { target: { value: "" } });
fireEvent.keyDown(input, { key: "Enter" }); fireEvent.keyDown(input, { key: "Enter" });
expect(screen.getByTestId("cell-editor-error")).toBeInTheDocument(); expect(screen.getByTestId("cell-editor-error")).toBeInTheDocument();
+103 -1
View File
@@ -30,6 +30,45 @@ const controlClass =
/** Types that tolerate an empty string when NOT NULL ('' is a valid value). */ /** Types that tolerate an empty string when NOT NULL ('' is a valid value). */
const TEXT_LIKE = ["char", "text", "uuid", "bit"]; const TEXT_LIKE = ["char", "text", "uuid", "bit"];
/** Map a column data type to a "smart" input kind. */
type InputKind = "date" | "datetime" | "time" | "boolean" | "number" | "text";
const NUMERIC_TYPES = new Set([
"integer", "bigint", "smallint", "int", "int2", "int4", "int8",
"tinyint", "mediumint", "numeric", "decimal", "real", "double",
"double precision", "float", "float4", "float8", "money",
"serial", "bigserial", "smallserial",
]);
const INTEGER_TYPES = new Set([
"integer", "bigint", "smallint", "int", "int2", "int4", "int8",
"tinyint", "mediumint", "serial", "bigserial", "smallserial",
]);
function inputKindForType(dataType: string): InputKind {
const t = dataType.toLowerCase().trim();
if (t === "date") return "date";
if (t.includes("timestamp") || t === "datetime") return "datetime";
if (t === "time" || t === "timetz" || t.startsWith("time ")) return "time";
if (t === "boolean" || t === "bool") return "boolean";
if (NUMERIC_TYPES.has(t)) return "number";
return "text";
}
/** Convert a DB timestamp ("2024-01-15 10:30:00") to datetime-local ("2024-01-15T10:30"). */
function toDatetimeLocal(v: string): string {
const m = v.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})/);
return m ? `${m[1]}T${m[2]}` : v;
}
/** Convert a datetime-local value ("2024-01-15T10:30") to the canonical DB
* format ("2024-01-15 10:30:00") accepted by both PostgreSQL and MySQL. */
function fromDatetimeLocal(v: string): string {
const m = v.match(/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})(?::(\d{2}))?/);
if (m) return `${m[1]} ${m[2]}:${m[3] ?? "00"}`;
return v;
}
export function CellEditor({ export function CellEditor({
initialValue, initialValue,
dataType, dataType,
@@ -40,7 +79,11 @@ export function CellEditor({
onCommit, onCommit,
onCancel, onCancel,
}: CellEditorProps) { }: CellEditorProps) {
const [value, setValue] = useState(initialValue); const kind = inputKindForType(dataType);
const isInteger = INTEGER_TYPES.has(dataType.toLowerCase().trim());
const [value, setValue] = useState(
kind === "datetime" ? toDatetimeLocal(initialValue) : initialValue,
);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
@@ -73,6 +116,8 @@ export function CellEditor({
useEffect(() => { useEffect(() => {
if (enumValues && enumValues.length > 0) { if (enumValues && enumValues.length > 0) {
enumRef.current?.focus(); enumRef.current?.focus();
} else if (kind === "boolean") {
enumRef.current?.focus();
} else if (fkOptions && fkOptions.length > 0) { } else if (fkOptions && fkOptions.length > 0) {
searchRef.current?.focus(); searchRef.current?.focus();
searchRef.current?.select(); searchRef.current?.select();
@@ -258,6 +303,63 @@ export function CellEditor({
); );
} }
if (kind === "boolean") {
return (
<div ref={rootRef} className={`flex h-full w-full items-center gap-1.5 px-1.5 ${error ? "ring-1 ring-inset ring-red-500/60" : ""}`}>
<select
ref={enumRef}
className={controlClass}
value={initialValue}
onChange={(e) => commitRaw(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
}}
>
<option value="">{nullable ? "NULL" : "—"}</option>
<option value="true">true</option>
<option value="false">false</option>
</select>
{errorBubble}
</div>
);
}
if (kind === "date" || kind === "datetime" || kind === "time" || kind === "number") {
const inputType = kind === "datetime" ? "datetime-local" : kind;
return (
<div
ref={rootRef}
data-testid="cell-editor"
className={`flex h-full w-full items-center gap-2 ${error ? "ring-1 ring-inset ring-red-500/60" : ""}`}
>
<input
ref={ref as any}
type={inputType}
className={inputClass}
value={value}
step={kind === "number" ? (isInteger ? "1" : "any") : undefined}
onChange={(e) => {
setValue(e.target.value);
setError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commitRaw(kind === "datetime" ? fromDatetimeLocal(value) : value);
} else if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
}}
/>
{errorBubble}
</div>
);
}
const cls = large const cls = large
? `${inputClass} h-4 resize-none overflow-y-auto whitespace-pre leading-none py-0.5` ? `${inputClass} h-4 resize-none overflow-y-auto whitespace-pre leading-none py-0.5`
: inputClass; : inputClass;
@@ -48,6 +48,21 @@ describe("VirtualDataGrid", () => {
expect(screen.getByRole("textbox")).toBeInTheDocument(); expect(screen.getByRole("textbox")).toBeInTheDocument();
}); });
it("renders a pending insert row with an 'auto' placeholder and no selection checkbox", () => {
const rows = [[null, null], ...mockRows];
mockGetTotalSize.mockReturnValue(rows.length * 36);
mockGetVirtualItems.mockReturnValue(rows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
render(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={rows} columns={mockColumns}
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
dbType="postgresql" tabType="table" pendingInsertChangeIds={["ch-1"]} />);
// Non-editable PK column in the insert row shows "auto" instead of NULL.
expect(screen.getByText("auto")).toBeInTheDocument();
// The insert row shows a "+" marker instead of a selection checkbox.
expect(screen.getByTitle("New row")).toBeInTheDocument();
// Real rows still render their checkboxes (2 real rows).
expect(screen.getAllByRole("checkbox")).toHaveLength(3); // 1 select-all + 2 row checkboxes
});
it("shows staged values passed from the parent + a pending outline", () => { it("shows staged values passed from the parent + a pending outline", () => {
mockGetTotalSize.mockReturnValue(mockRows.length * 36); mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 }))); mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
+84 -49
View File
@@ -45,6 +45,10 @@ interface VirtualDataGridProps {
stagedValues?: Record<string, string | null>; stagedValues?: Record<string, string | null>;
/** Keys of cells with a PENDING (not yet committed) update → drives the pulsing orange outline. */ /** Keys of cells with a PENDING (not yet committed) update → drives the pulsing orange outline. */
pendingKeys?: Record<string, boolean>; pendingKeys?: Record<string, boolean>;
/** Ordered change ids of pending insert rows prepended to `rows`. */
pendingInsertChangeIds?: string[];
/** Stage a cell edit on a pending insert row (updates the insert's newData). */
onStageInsertCell?: (changeId: string, column: string, value: string | null) => void;
} }
const ROW_HEIGHT = 36; const ROW_HEIGHT = 36;
@@ -74,20 +78,24 @@ export function VirtualDataGrid({
fkPlaceholders, fkPlaceholders,
stagedValues, stagedValues,
pendingKeys, pendingKeys,
pendingInsertChangeIds,
onStageInsertCell,
}: VirtualDataGridProps) { }: VirtualDataGridProps) {
const parentRef = useRef<HTMLDivElement>(null); const parentRef = useRef<HTMLDivElement>(null);
const visibleColumns = columns.filter((c) => !hiddenColumns.has(c.name)); const visibleColumns = columns.filter((c) => !hiddenColumns.has(c.name));
const hasColumns = columns.length > 0; const hasColumns = columns.length > 0;
const allSelected = rows.length > 0 && selectedRows.size === rows.length; const pendingInsertCount = pendingInsertChangeIds?.length ?? 0;
const realRowCount = rows.length - pendingInsertCount;
const allSelected = realRowCount > 0 && selectedRows.size === realRowCount;
const selectAllRef = useRef<HTMLInputElement>(null); const selectAllRef = useRef<HTMLInputElement>(null);
// Indeterminate state for partial selection // Indeterminate state for partial selection
useEffect(() => { useEffect(() => {
if (selectAllRef.current) { if (selectAllRef.current) {
selectAllRef.current.indeterminate = selectedRows.size > 0 && selectedRows.size < rows.length; selectAllRef.current.indeterminate = selectedRows.size > 0 && selectedRows.size < realRowCount;
} }
}, [selectedRows.size, rows.length]); }, [selectedRows.size, realRowCount]);
const virtualizer = useVirtualizer({ const virtualizer = useVirtualizer({
count: rows.length, count: rows.length,
@@ -282,6 +290,8 @@ export function VirtualDataGrid({
const isJson = !isNull && (col.data_type === "jsonb" || col.data_type === "json"); const isJson = !isNull && (col.data_type === "jsonb" || col.data_type === "json");
const jp = isJson ? jsonPreview(displayCell) : { label: "", isJson: false }; const jp = isJson ? jsonPreview(displayCell) : { label: "", isJson: false };
const editable = isCellEditable(col, tabType, dbType, readOnly); const editable = isCellEditable(col, tabType, dbType, readOnly);
const isInsertRow = rowIndex < pendingInsertCount;
const insertChangeId = isInsertRow ? pendingInsertChangeIds?.[rowIndex] : undefined;
const isActive = activeCell?.row === rowIndex && activeCell?.col === colIndex; const isActive = activeCell?.row === rowIndex && activeCell?.col === colIndex;
const isEditing = editingCell?.row === rowIndex && editingCell?.col === colIndex; const isEditing = editingCell?.row === rowIndex && editingCell?.col === colIndex;
const isPending = const isPending =
@@ -307,23 +317,27 @@ export function VirtualDataGrid({
// revert/display stays correct even after repeated edits of the same cell. // revert/display stays correct even after repeated edits of the same cell.
const dbValue = ci >= 0 ? row[ci] : undefined; const dbValue = ci >= 0 ? row[ci] : undefined;
if (committed !== (dbValue === null || dbValue === undefined ? null : dbValue)) { if (committed !== (dbValue === null || dbValue === undefined ? null : dbValue)) {
const locator = getLocator?.(row) ?? {}; if (isInsertRow && insertChangeId) {
onStageEdit?.( onStageInsertCell?.(insertChangeId, col.name, committed);
cellToUpdateChange({ } else {
schema, const locator = getLocator?.(row) ?? {};
table, onStageEdit?.(
primaryKey: locator, cellToUpdateChange({
oldData: { [col.name]: dbValue }, schema,
newData: { [col.name]: committed }, table,
}) as { primaryKey: locator,
type: "update"; oldData: { [col.name]: dbValue },
schema: string; newData: { [col.name]: committed },
table: string; }) as {
primaryKey: Record<string, unknown>; type: "update";
oldData: Record<string, unknown>; schema: string;
newData: Record<string, unknown>; table: string;
}, primaryKey: Record<string, unknown>;
); oldData: Record<string, unknown>;
newData: Record<string, unknown>;
},
);
}
} }
setPendingCellKey(cellKey); setPendingCellKey(cellKey);
setEditingCell(null); setEditingCell(null);
@@ -355,13 +369,15 @@ export function VirtualDataGrid({
} }
style={{ width: getWidth(col.name), flexShrink: 0 }} style={{ width: getWidth(col.name), flexShrink: 0 }}
title={ title={
isNull isInsertRow && !editable
? "NULL" ? "auto (default)"
: isFk : isNull
? `FK → ${col.fk_ref![0]}.${col.fk_ref![1]}: ${String(displayCell)}` ? "NULL"
: isJson : isFk
? "Click to view JSON" ? `FK → ${col.fk_ref![0]}.${col.fk_ref![1]}: ${String(displayCell)}`
: String(displayCell) : isJson
? "Click to view JSON"
: String(displayCell)
} }
onClick={(e) => { onClick={(e) => {
setActiveCell({ row: rowIndex, col: colIndex }); setActiveCell({ row: rowIndex, col: colIndex });
@@ -390,6 +406,8 @@ export function VirtualDataGrid({
onCancel={() => setEditingCell(null)} onCancel={() => setEditingCell(null)}
/> />
</div> </div>
) : isInsertRow && !editable ? (
<span className="italic text-text-muted/60">auto</span>
) : isNull ? ( ) : isNull ? (
<span className="italic text-text-muted">NULL</span> <span className="italic text-text-muted">NULL</span>
) : isJson ? ( ) : isJson ? (
@@ -420,7 +438,7 @@ export function VirtualDataGrid({
</div> </div>
); );
}, },
[activeCell, columns, dbType, editingCell, enumValues, fkOptions, fkPlaceholders, getLocator, handleFkClick, onStageEdit, schema, table, tabType, getWidth, pendingCell, stagedValues, pendingKeys, pendingCellKey], [activeCell, columns, dbType, editingCell, enumValues, fkOptions, fkPlaceholders, getLocator, handleFkClick, onStageEdit, onStageInsertCell, schema, table, tabType, getWidth, pendingCell, stagedValues, pendingKeys, pendingCellKey, pendingInsertCount, pendingInsertChangeIds],
); );
// ── context menu helpers ────────────────────────────── // ── context menu helpers ──────────────────────────────
@@ -446,6 +464,11 @@ export function VirtualDataGrid({
const ci = columns.findIndex((c) => c.name === column.name); const ci = columns.findIndex((c) => c.name === column.name);
const value = rows[row]?.[ci]; const value = rows[row]?.[ci];
if (value === null || value === undefined) return; if (value === null || value === undefined) return;
if (row < pendingInsertCount) {
const changeId = pendingInsertChangeIds?.[row];
if (changeId) onStageInsertCell?.(changeId, column.name, null);
return;
}
const locator = getLocator?.(rows[row]) ?? {}; const locator = getLocator?.(rows[row]) ?? {};
onStageEdit?.( onStageEdit?.(
cellToUpdateChange({ cellToUpdateChange({
@@ -464,7 +487,7 @@ export function VirtualDataGrid({
}, },
); );
}, },
[columns, dbType, getLocator, onStageEdit, readOnly, rows, schema, table, tabType, visibleColumns], [columns, dbType, getLocator, onStageEdit, onStageInsertCell, readOnly, rows, schema, table, tabType, visibleColumns, pendingInsertCount, pendingInsertChangeIds],
); );
return ( return (
@@ -528,15 +551,18 @@ export function VirtualDataGrid({
}} }}
> >
{virtualizer.getVirtualItems().map((virtualRow) => { {virtualizer.getVirtualItems().map((virtualRow) => {
const row = rows[virtualRow.index]; const gridIndex = virtualRow.index;
const isSelected = selectedRows.has(virtualRow.index); const isInsertRow = gridIndex < pendingInsertCount;
const realIndex = isInsertRow ? -1 : gridIndex - pendingInsertCount;
const row = rows[gridIndex];
const isSelected = !isInsertRow && selectedRows.has(realIndex);
return ( return (
<div <div
key={virtualRow.key} key={virtualRow.key}
data-index={virtualRow.index} data-index={gridIndex}
className={`flex items-center border-b border-border ${ className={`flex items-center border-b border-border ${
isSelected ? "bg-accent/5" : "" isSelected ? "bg-accent/5" : ""
} hover:bg-surface/50`} } ${isInsertRow ? "bg-accent/[0.03] ring-1 ring-inset ring-accent/60" : ""} hover:bg-surface/50`}
style={{ style={{
position: "absolute", position: "absolute",
top: 0, top: 0,
@@ -546,20 +572,28 @@ export function VirtualDataGrid({
transform: `translateY(${virtualRow.start}px)`, transform: `translateY(${virtualRow.start}px)`,
}} }}
> >
{hasColumns && ( {hasColumns &&
<div (isInsertRow ? (
style={{ width: 40, minWidth: 40 }} <div
className="flex items-center justify-center border-r border-border self-stretch" style={{ width: 40, minWidth: 40 }}
> className="flex items-center justify-center border-r border-border self-stretch"
<input >
type="checkbox" <span className="text-[10px] text-accent/70" title="New row">+</span>
checked={isSelected} </div>
onChange={() => onToggleRow(virtualRow.index)} ) : (
className="w-3.5 h-3.5 rounded border-border cursor-pointer accent-accent" <div
/> style={{ width: 40, minWidth: 40 }}
</div> className="flex items-center justify-center border-r border-border self-stretch"
)} >
{visibleColumns.map((col, i) => renderCell(col, row, virtualRow.index, i))} <input
type="checkbox"
checked={isSelected}
onChange={() => onToggleRow(realIndex)}
className="w-3.5 h-3.5 rounded border-border cursor-pointer accent-accent"
/>
</div>
))}
{visibleColumns.map((col, i) => renderCell(col, row, gridIndex, i))}
</div> </div>
); );
})} })}
@@ -601,6 +635,7 @@ export function VirtualDataGrid({
isJson={ctxCol.data_type === "jsonb" || ctxCol.data_type === "json"} isJson={ctxCol.data_type === "jsonb" || ctxCol.data_type === "json"}
isFk={ctxCol.is_fk && ctxCol.fk_ref != null} isFk={ctxCol.is_fk && ctxCol.fk_ref != null}
nullable={ctxCol.is_nullable} nullable={ctxCol.is_nullable}
isInsertRow={ctxMenu.row < pendingInsertCount}
onCopy={() => { onCopy={() => {
void copyCellValue(ctxMenu.row, ctxMenu.col); void copyCellValue(ctxMenu.row, ctxMenu.col);
setCtxMenu(null); setCtxMenu(null);
@@ -610,11 +645,11 @@ export function VirtualDataGrid({
setCtxMenu(null); setCtxMenu(null);
}} }}
onViewRow={() => { onViewRow={() => {
onOpenRowDetail?.(ctxMenu.row); onOpenRowDetail?.(ctxMenu.row - pendingInsertCount);
setCtxMenu(null); setCtxMenu(null);
}} }}
onSelectRow={() => { onSelectRow={() => {
onToggleRow(ctxMenu.row); onToggleRow(ctxMenu.row - pendingInsertCount);
setCtxMenu(null); setCtxMenu(null);
}} }}
onEdit={() => { onEdit={() => {
+3 -3
View File
@@ -1,11 +1,11 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import tauriConf from "../../src-tauri/tauri.conf.json"; import tauriConf from "../../src-tauri/tauri.conf.json";
describe("tauri bundle config (v0.7.14)", () => { describe("tauri bundle config (v0.7.15)", () => {
it("declares bundled pg_tools resources", () => { it("declares bundled pg_tools resources", () => {
expect(tauriConf.bundle.resources).toContain("resources/pg_tools/*"); expect(tauriConf.bundle.resources).toContain("resources/pg_tools/*");
}); });
it("version is 0.7.14", () => { it("version is 0.7.15", () => {
expect(tauriConf.version).toBe("0.7.14"); expect(tauriConf.version).toBe("0.7.15");
}); });
}); });
+4
View File
@@ -45,6 +45,10 @@ describe("buildChangeSql", () => {
const item = { id: "1", type: "insert", schema: "public", table: "t", newData: { a: 1, b: "x" } } as any; const item = { id: "1", type: "insert", schema: "public", table: "t", newData: { a: 1, b: "x" } } as any;
expect(buildChangeSql(item)).toBe('INSERT INTO "public"."t" ("a", "b") VALUES (1, \'x\')'); expect(buildChangeSql(item)).toBe('INSERT INTO "public"."t" ("a", "b") VALUES (1, \'x\')');
}); });
it("insert with no columns falls back to DEFAULT VALUES", () => {
const item = { id: "1", type: "insert", schema: "public", table: "t", newData: {} } as any;
expect(buildChangeSql(item)).toBe('INSERT INTO "public"."t" DEFAULT VALUES');
});
it("update", () => { it("update", () => {
const item = { id: "1", type: "update", schema: "public", table: "t", primaryKey: { id: 5 }, newData: { name: "O'Brien" } } as any; const item = { id: "1", type: "update", schema: "public", table: "t", primaryKey: { id: 5 }, newData: { name: "O'Brien" } } as any;
expect(buildChangeSql(item)).toBe('UPDATE "public"."t" SET "name" = \'O\'\'Brien\' WHERE "id" = 5'); expect(buildChangeSql(item)).toBe('UPDATE "public"."t" SET "name" = \'O\'\'Brien\' WHERE "id" = 5');
+1
View File
@@ -33,6 +33,7 @@ export function buildChangeSql(item: QueueItem): string {
case "insert": { case "insert": {
const data = item.newData ?? {}; const data = item.newData ?? {};
const cols = Object.keys(data); const cols = Object.keys(data);
if (cols.length === 0) return `INSERT INTO ${t} DEFAULT VALUES`;
return `INSERT INTO ${t} (${cols.map(q).join(", ")}) VALUES (${cols.map((c) => lit(data[c])).join(", ")})`; return `INSERT INTO ${t} (${cols.map(q).join(", ")}) VALUES (${cols.map((c) => lit(data[c])).join(", ")})`;
} }
case "update": { case "update": {
+11 -7
View File
@@ -3,8 +3,9 @@ import { describe, it, expect } from "vitest";
// test free of a `node:fs` dependency so `tsc` (bun run build) stays clean. // test free of a `node:fs` dependency so `tsc` (bun run build) stays clean.
import agents from "../../../AGENTS.md?raw"; import agents from "../../../AGENTS.md?raw";
import readme from "../../../README.md?raw"; import readme from "../../../README.md?raw";
import landing from "../../../www/public/landing.js?raw";
describe("v0.7.14 docs coverage", () => { describe("v0.7.15 docs coverage", () => {
it("AGENTS.md marks inline cell editing complete", () => { it("AGENTS.md marks inline cell editing complete", () => {
expect(agents).toContain("Inline cell editing"); expect(agents).toContain("Inline cell editing");
expect(agents).toMatch(/Inline cell editing \| ✅/); expect(agents).toMatch(/Inline cell editing \| ✅/);
@@ -24,8 +25,8 @@ describe("v0.7.14 docs coverage", () => {
expect(agents).toMatch(/Connection status indicator on cards \| ✅/); expect(agents).toMatch(/Connection status indicator on cards \| ✅/);
expect(agents).toMatch(/Move-to-folder bulk action \| ✅/); expect(agents).toMatch(/Move-to-folder bulk action \| ✅/);
}); });
it("README declares v0.7.14", () => { it("README declares v0.7.15", () => {
expect(readme).toContain("0.7.14"); expect(readme).toContain("0.7.15");
}); });
it("AGENTS.md marks schema CRUD complete", () => { it("AGENTS.md marks schema CRUD complete", () => {
expect(agents).toMatch(/Schema CRUD \| ✅/); expect(agents).toMatch(/Schema CRUD \| ✅/);
@@ -64,9 +65,12 @@ describe("v0.7.14 docs coverage", () => {
expect(agents).toMatch(/Cancel long-running queries \| ✅/); expect(agents).toMatch(/Cancel long-running queries \| ✅/);
expect(agents).toMatch(/Settings export\/import \| ✅/); expect(agents).toMatch(/Settings export\/import \| ✅/);
}); });
it("README links to v0.7.14 assets in both download tables", () => { it("README links to v0.7.15 assets in both download tables", () => {
expect(readme).toContain("releases/download/v0.7.14/"); expect(readme).toContain("releases/download/v0.7.15/");
expect(readme).toContain("Gridline_0.7.14_aarch64.dmg"); expect(readme).toContain("Gridline_0.7.15_aarch64.dmg");
expect(readme).toContain("Gridline-0.7.14-1.x86_64.rpm"); expect(readme).toContain("Gridline-0.7.15-1.x86_64.rpm");
});
it("landing.js platform download links point to v0.7.15", () => {
expect(landing).toContain('const version = "0.7.15"');
}); });
}); });
+2 -2
View File
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
import pkg from "../../package.json"; import pkg from "../../package.json";
describe("version", () => { describe("version", () => {
it("declares v0.7.14 across the app shell", () => { it("declares v0.7.15 across the app shell", () => {
expect(pkg.version).toBe("0.7.14"); expect(pkg.version).toBe("0.7.15");
}); });
}); });
+10
View File
@@ -247,6 +247,16 @@ describe("dbViewerStore", () => {
expect(useDbViewerStore.getState().changesQueue).toHaveLength(0); expect(useDbViewerStore.getState().changesQueue).toHaveLength(0);
}); });
it("updateChange patches a single change in place", () => {
useDbViewerStore.getState().addChange({ type: "insert", schema: "public", table: "t", newData: { a: null }, description: "x" } as any);
const id = useDbViewerStore.getState().changesQueue[0].id;
useDbViewerStore.getState().updateChange(id, { newData: { a: "hello" } });
const item = useDbViewerStore.getState().changesQueue[0];
expect(item.newData).toEqual({ a: "hello" });
expect(item.type).toBe("insert");
expect(item.description).toBe("x");
});
it("clearChanges empties the queue", () => { it("clearChanges empties the queue", () => {
useDbViewerStore.getState().addChange({ type: "insert", schema: "public", table: "t", newData: { a: 1 }, description: "x" } as any); useDbViewerStore.getState().addChange({ type: "insert", schema: "public", table: "t", newData: { a: 1 }, description: "x" } as any);
useDbViewerStore.getState().addChange({ type: "delete", schema: "public", table: "t", primaryKey: { id: 1 }, description: "y" } as any); useDbViewerStore.getState().addChange({ type: "delete", schema: "public", table: "t", primaryKey: { id: 1 }, description: "y" } as any);
+8
View File
@@ -167,6 +167,7 @@ interface DbViewerState {
}) => void; }) => void;
cancelChange: (changeId: string) => void; cancelChange: (changeId: string) => void;
removeChange: (changeId: string) => void; removeChange: (changeId: string) => void;
updateChange: (changeId: string, patch: Partial<QueueItem>) => void;
clearChanges: () => void; clearChanges: () => void;
markChangeCommitted: (changeId: string) => void; markChangeCommitted: (changeId: string) => void;
markChangeFailed: (changeId: string, error: string) => void; markChangeFailed: (changeId: string, error: string) => void;
@@ -547,6 +548,13 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
changesQueue: state.changesQueue.filter((c) => c.id !== changeId), changesQueue: state.changesQueue.filter((c) => c.id !== changeId),
})), })),
updateChange: (changeId, patch) =>
set((state) => ({
changesQueue: state.changesQueue.map((c) =>
c.id === changeId ? { ...c, ...patch } : c,
),
})),
clearChanges: () => set({ changesQueue: [] }), clearChanges: () => set({ changesQueue: [] }),
markChangeCommitted: (changeId) => markChangeCommitted: (changeId) =>
+27 -47
View File
@@ -7,13 +7,12 @@
# #
# Usage: # Usage:
# TAG=v0.7.11 ./scripts/release-mac.sh # TAG=v0.7.11 ./scripts/release-mac.sh
# (optionally set GITEA_TOKEN env var to override token lookup)
# #
# The release is keyed by tag. The Linux/Windows job creates it on the first # The release is keyed by tag. The Linux/Windows job creates it on the first
# tag push; this script finds it (creating it only if it somehow doesn't exist) # tag push; this script finds it (creating it only if it somehow doesn't exist)
# and attaches the two DMGs: Gridline_<ver>_aarch64.dmg and Gridline_<ver>_x64.dmg # and attaches the two DMGs: Gridline_<ver>_aarch64.dmg and Gridline_<ver>_x64.dmg
# #
# Token: reads $GITEA_TOKEN, else tries the tea CLI config, else prompts. # Auth: uses the `tea` CLI (OAuth token, auto-refreshed).
set -euo pipefail set -euo pipefail
@@ -28,28 +27,15 @@ if [ -z "$TAG" ]; then
exit 1 exit 1
fi fi
# ---- resolve auth token ---------------------------------------------------- # ---- resolve auth (tea CLI) ------------------------------------------------
get_token() { # The `tea` CLI carries the OAuth token and refreshes it automatically, so the
if [ -n "${GITEA_TOKEN:-}" ]; then # release upload below uses `tea` rather than a raw curl + token. (The git
echo "$GITEA_TOKEN"; return # credential store can hold a stale access token and 401.)
fi if ! command -v tea >/dev/null 2>&1; then
# Try tea's stored credentials if available. echo "error: tea CLI not found (needed to upload release assets)" >&2
if command -v tea >/dev/null 2>&1; then echo " install it: https://gitea.com/gitea/tea" >&2
# tea stores its token in a config under the platform app-support dir. exit 1
local cfg fi
cfg="$(find "$HOME/Library/Application Support" "$HOME/.config" \
-maxdepth 2 -name 'config.yml' -path '*tea*' 2>/dev/null | head -1 || true)"
if [ -n "$cfg" ]; then
local tok
tok="$(sed -n 's/.*token:[" ]*\([^" ]*\).*/\1/p' "$cfg" 2>/dev/null | head -1 || true)"
if [ -n "$tok" ]; then echo "$tok"; return; fi
fi
fi
read -rsp "Gitea personal access token (repo write): " GITEA_TOKEN
echo "$GITEA_TOKEN"
}
GITEA_TOKEN="$(get_token)"
# ---- macOS signing + notarization ----------------------------------------- # ---- macOS signing + notarization -----------------------------------------
# Requires a "Developer ID Application" certificate in the login keychain and # Requires a "Developer ID Application" certificate in the login keychain and
@@ -112,6 +98,8 @@ echo ">> Building PostgreSQL client tools..."
mkdir -p "$OUT_PG" mkdir -p "$OUT_PG"
PG_VER="16.4" PG_VER="16.4"
curl -fsSL "https://ftp.postgresql.org/pub/source/v${PG_VER}/postgresql-${PG_VER}.tar.bz2" -o /tmp/pg.tar.bz2 curl -fsSL "https://ftp.postgresql.org/pub/source/v${PG_VER}/postgresql-${PG_VER}.tar.bz2" -o /tmp/pg.tar.bz2
# Idempotent re-runs: clear stale build dirs left by previous invocations.
rm -rf "/tmp/postgresql-${PG_VER}" /tmp/pgbuild
tar -xf /tmp/pg.tar.bz2 -C /tmp tar -xf /tmp/pg.tar.bz2 -C /tmp
cd /tmp/postgresql-${PG_VER} cd /tmp/postgresql-${PG_VER}
ac_cv_func_strchrnul=no ./configure --prefix=/tmp/pgbuild --without-readline --without-icu CFLAGS="-O2" ac_cv_func_strchrnul=no ./configure --prefix=/tmp/pgbuild --without-readline --without-icu CFLAGS="-O2"
@@ -137,6 +125,9 @@ done
echo ">> Building MariaDB client tools..." echo ">> Building MariaDB client tools..."
mkdir -p "$OUT_MY" mkdir -p "$OUT_MY"
MARIADB_VER="11.4.5" MARIADB_VER="11.4.5"
# Idempotent re-runs: git clone fails if the destination is non-empty (e.g.
# left over from an earlier build on the same machine). Clean it first.
rm -rf /tmp/mariadb-server
git clone --depth 1 --branch "mariadb-${MARIADB_VER}" https://github.com/MariaDB/server.git /tmp/mariadb-server git clone --depth 1 --branch "mariadb-${MARIADB_VER}" https://github.com/MariaDB/server.git /tmp/mariadb-server
cd /tmp/mariadb-server cd /tmp/mariadb-server
if [ "$(uname -m)" = arm64 ]; then if [ "$(uname -m)" = arm64 ]; then
@@ -207,32 +198,21 @@ for APP in \
done done
# ---- create/ensure release + upload DMGs ----------------------------------- # ---- create/ensure release + upload DMGs -----------------------------------
API="${GITEA_SERVER}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}" # Use `tea` for release management — it handles OAuth token refresh, unlike a
AUTH="Authorization: token ${GITEA_TOKEN}" # raw curl with a token from the git credential store (which can 401).
cd "${WORKSPACE}"
echo ">> Ensuring release ${TAG} exists..." echo ">> Ensuring release ${TAG} exists..."
REL_ID=$(curl -fsS -H "$AUTH" "${API}/releases/tags/${TAG}" \ if ! tea release list -o json 2>/dev/null | grep -q "\"${TAG}\""; then
| python3 -c "import sys,json;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true) tea release create --tag "${TAG}" --title "Gridline ${VERSION}" \
if [ -z "$REL_ID" ]; then --note "Gridline ${VERSION} — macOS (built manually)." --draft
BODY="Gridline ${VERSION} — macOS (built manually)." else
REL_ID=$(curl -fsS -X POST -H "$AUTH" -H "Content-Type: application/json" \ echo ">> Release ${TAG} already exists."
-d "{\"tag_name\":\"${TAG}\",\"name\":\"Gridline ${VERSION}\",\"body\":\"${BODY}\",\"draft\":true}" \
"${API}/releases" \
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
fi fi
upload() { echo ">> Uploading macOS DMGs..."
local file="$1" tea releases assets create "${TAG}" \
[ -f "$file" ] || { echo "missing $file" >&2; exit 1; } "${BUNDLE_ARM}/dmg/Gridline_${VERSION}_aarch64.dmg" \
local name "${BUNDLE_INTEL}/dmg/Gridline_${VERSION}_x64.dmg"
name=$(basename "$file")
echo ">> uploading $name"
curl -fsS -X POST -H "$AUTH" -H "Content-Type: application/octet-stream" \
--data-binary "@$file" \
"${API}/releases/${REL_ID}/assets?name=${name}"
}
upload "${BUNDLE_ARM}/dmg/Gridline_${VERSION}_aarch64.dmg"
upload "${BUNDLE_INTEL}/dmg/Gridline_${VERSION}_x64.dmg"
echo "Done. Release: ${GITEA_SERVER}/${REPO_OWNER}/${REPO_NAME}/releases/tag/${TAG}" echo "Done. Release: ${GITEA_SERVER}/${REPO_OWNER}/${REPO_NAME}/releases/tag/${TAG}"
+1 -1
View File
@@ -60,7 +60,7 @@
const keepLabel = downloadBtn.hasAttribute("data-download-label"); const keepLabel = downloadBtn.hasAttribute("data-download-label");
const releasesUrl = "https://git.ranio.xyz/adrianbonpin/gridline/releases"; const releasesUrl = "https://git.ranio.xyz/adrianbonpin/gridline/releases";
const version = "0.7.14"; const version = "0.7.15";
if (isMobile) { if (isMobile) {
downloadBtn.href = releasesUrl; downloadBtn.href = releasesUrl;
+10
View File
@@ -5,6 +5,16 @@ export interface ChangelogEntry {
} }
export const changelogEntries: ChangelogEntry[] = [ export const changelogEntries: ChangelogEntry[] = [
{
version: "0.7.15",
date: "2026-09-08",
highlights: [
"Insert Row now works — it adds an editable pending row at the top of the data grid (with a visible accent outline), and committing inserts only the columns you filled in so serial/identity/generated defaults apply.",
"Smart cell editors — date/time/datetime pickers, a boolean select, and number inputs for the matching column types (enums and foreign keys already had dropdowns), applied to both new rows and inline editing.",
"Landing page adds a support channel: coffee via Ko-fi (ko-fi.com/adrianbonpin), always optional, never gates a feature.",
"Landing page copy refreshed: em dashes dropped, OSS section simplified, FAQ added for how the project is funded.",
],
},
{ {
version: "0.7.14", version: "0.7.14",
date: "2026-09-04", date: "2026-09-04",
+9 -4
View File
@@ -7,12 +7,12 @@ export const faqItems: FaqItem[] = [
{ {
question: "Is Gridline really free for commercial use?", question: "Is Gridline really free for commercial use?",
answer: answer:
"Yes. Gridline is released under the Apache 2.0 license. You can use it for personal, commercial, or internal company work without paying or signing up.", "Yes. Gridline is Apache 2.0. You can use it for personal, commercial, or internal work without paying or signing up.",
}, },
{ {
question: "Which databases are supported?", question: "Which databases are supported?",
answer: answer:
"PostgreSQL and SQLite are fully supported today, including browsing, querying, editing, backups, and visual tools. MySQL and Redis connections are in active development.", "PostgreSQL and SQLite are fully supported today (browsing, querying, editing, backups, and the visual tools). MySQL and Redis connections are in active development.",
}, },
{ {
question: "How is this different from TablePlus, Beekeeper Studio, or DB Pro?", question: "How is this different from TablePlus, Beekeeper Studio, or DB Pro?",
@@ -22,10 +22,15 @@ export const faqItems: FaqItem[] = [
{ {
question: "Do you store my database credentials?", question: "Do you store my database credentials?",
answer: answer:
"Passwords are stored in your operating system keychain — macOS Keychain, Linux Secret Service, or Windows Credential Manager — never in Gridline's local SQLite database.", "No. Passwords live in your operating system keychain (macOS Keychain, Linux Secret Service, or Windows Credential Manager), never in Gridline's local SQLite database.",
}, },
{ {
question: "Can I self-host or contribute?", question: "How do you make money?",
answer:
"Gridline is free and isn't built around a paywall. If it saves you time, you can buy a coffee at ko-fi.com/adrianbonpin. It's entirely optional and never gates a feature. Otherwise, issues, docs, and contributions all help.",
},
{
question: "Can I contribute or self-host?",
answer: answer:
"Gridline is a desktop app, not a SaaS, so there's nothing to self-host. The repo is open for contributions: code, docs, translations, and issue reports are all welcome.", "Gridline is a desktop app, not a SaaS, so there's nothing to self-host. The repo is open for contributions: code, docs, translations, and issue reports are all welcome.",
}, },
+5 -4
View File
@@ -13,7 +13,7 @@ export interface Props {
const { const {
title, title,
description = "Gridline — the open-source database GUI for PostgreSQL, MySQL, SQLite, and Redis. No paywalls, no tab limits, fully open source.", description = "Gridline, the open-source database GUI for PostgreSQL, MySQL, SQLite, and Redis. No paywalls, no tab limits, fully open source.",
ogImage = "/og.png", ogImage = "/og.png",
noIndex = false, noIndex = false,
jsonLd = [], jsonLd = [],
@@ -51,7 +51,7 @@ const isProd = import.meta.env.PROD;
<meta property="og:image" content={ogImageURL} /> <meta property="og:image" content={ogImageURL} />
<meta property="og:image:width" content="1200" /> <meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" /> <meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="Gridline — the open-source database GUI" /> <meta property="og:image:alt" content="Gridline, the open-source database GUI" />
<meta property="og:site_name" content="Gridline" /> <meta property="og:site_name" content="Gridline" />
<meta property="og:locale" content="en_US" /> <meta property="og:locale" content="en_US" />
@@ -59,7 +59,7 @@ const isProd = import.meta.env.PROD;
<meta name="twitter:title" content={title} /> <meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} /> <meta name="twitter:description" content={description} />
<meta name="twitter:image" content={ogImageURL} /> <meta name="twitter:image" content={ogImageURL} />
<meta name="twitter:image:alt" content="Gridline — the open-source database GUI" /> <meta name="twitter:image:alt" content="Gridline, the open-source database GUI" />
<meta name="twitter:site" content="@AdrianBonpin" /> <meta name="twitter:site" content="@AdrianBonpin" />
<meta name="twitter:creator" content="@AdrianBonpin" /> <meta name="twitter:creator" content="@AdrianBonpin" />
@@ -149,12 +149,13 @@ const isProd = import.meta.env.PROD;
<ul class="space-y-2 text-sm text-[var(--text-muted)]"> <ul class="space-y-2 text-sm text-[var(--text-muted)]">
<li><a href="https://x.com/AdrianBonpin" target="_blank" rel="noopener" class="footer-link">X / Twitter</a></li> <li><a href="https://x.com/AdrianBonpin" target="_blank" rel="noopener" class="footer-link">X / Twitter</a></li>
<li><a href="mailto:adrianbonpin@gmail.com" class="footer-link">Email</a></li> <li><a href="mailto:adrianbonpin@gmail.com" class="footer-link">Email</a></li>
<li><a href="https://ko-fi.com/adrianbonpin" target="_blank" rel="noopener" class="footer-link">Support on Ko-fi ☕</a></li>
</ul> </ul>
</div> </div>
</div> </div>
<div class="mt-12 pt-8 border-t border-[var(--border)] flex flex-col md:flex-row justify-between gap-3 text-xs text-[var(--text-muted)] font-mono"> <div class="mt-12 pt-8 border-t border-[var(--border)] flex flex-col md:flex-row justify-between gap-3 text-xs text-[var(--text-muted)] font-mono">
<span>© 2026 Gridline contributors · Apache 2.0</span> <span>© 2026 Gridline contributors · Apache 2.0</span>
<span>Made with care for databases.</span> <span>Coffee-powered.</span>
</div> </div>
</div> </div>
</footer> </footer>
+1 -1
View File
@@ -1,7 +1,7 @@
--- ---
import Layout from "../layouts/Layout.astro"; import Layout from "../layouts/Layout.astro";
const title = "Page not found — Gridline"; const title = "Page not found · Gridline";
const description = "That page doesn’t exist. Head back to Gridline, the open-source database GUI."; const description = "That page doesn’t exist. Head back to Gridline, the open-source database GUI.";
--- ---
+15 -7
View File
@@ -15,7 +15,7 @@ import queryEditor from "../assets/screenshots/query-editor.png";
import erDiagram from "../assets/screenshots/er-diagram.png"; import erDiagram from "../assets/screenshots/er-diagram.png";
import home from "../assets/screenshots/home.png"; import home from "../assets/screenshots/home.png";
import dataGrid from "../assets/screenshots/data-grid.png"; import dataGrid from "../assets/screenshots/data-grid.png";
const version = "0.7.14"; const version = "0.7.15";
const site = "https://getgridline.app"; const site = "https://getgridline.app";
const jsonLd = [ const jsonLd = [
buildSoftwareApplicationJsonLd({ buildSoftwareApplicationJsonLd({
@@ -58,7 +58,7 @@ const jsonLd = [
]), ]),
]; ];
--- ---
<Layout title="Gridline — Free Open-Source Database GUI" jsonLd={jsonLd}> <Layout title="Gridline, Free Open-Source Database GUI" jsonLd={jsonLd}>
<script src="/landing.js" defer></script> <script src="/landing.js" defer></script>
<!-- Hero --> <!-- Hero -->
@@ -70,7 +70,7 @@ const jsonLd = [
the open-source database <span class="text-[var(--accent)]">gui</span> the open-source database <span class="text-[var(--accent)]">gui</span>
</h1> </h1>
<p class="mt-6 text-lg md:text-xl text-[var(--text-muted)] max-w-xl mx-auto"> <p class="mt-6 text-lg md:text-xl text-[var(--text-muted)] max-w-xl mx-auto">
Query, explore, sync, and edit PostgreSQL, MySQL, SQLite, and Redis — with no paywalls, no tab limits, no account required. Query, explore, sync, and edit PostgreSQL, MySQL, SQLite, and Redis, with no paywalls, no tab limits, no account required.
</p> </p>
<div class="mt-8 flex flex-wrap items-center justify-center gap-3"> <div class="mt-8 flex flex-wrap items-center justify-center gap-3">
<a id="download" href="https://git.ranio.xyz/adrianbonpin/gridline/releases" class="primary-btn" data-download> <a id="download" href="https://git.ranio.xyz/adrianbonpin/gridline/releases" class="primary-btn" data-download>
@@ -176,13 +176,21 @@ const jsonLd = [
<section id="oss" class="section-animate py-20 md:py-28 text-center"> <section id="oss" class="section-animate py-20 md:py-28 text-center">
<div class="max-w-[800px] mx-auto px-6"> <div class="max-w-[800px] mx-auto px-6">
<p class="font-mono text-xs text-[var(--accent)] uppercase tracking-[0.15em] mb-3">Open Source</p> <p class="font-mono text-xs text-[var(--accent)] uppercase tracking-[0.15em] mb-3">Open Source</p>
<h2 class="font-serif text-3xl md:text-5xl mb-5">Built in public.<br />No VC roadmap, no rug pulls.</h2> <h2 class="font-serif text-3xl md:text-5xl mb-5">Free. Open source. Apache 2.0.</h2>
<p class="text-[var(--text-muted)] mb-8 max-w-md mx-auto"> <p class="text-[var(--text-muted)] mb-8 max-w-md mx-auto">
Gridline is Apache 2.0 and developed openly. Contributions, issues, and feature requests are welcome. Gridline is Apache 2.0 and developed openly. Contributions, issues, and feature requests are welcome.
</p> </p>
<a href="https://git.ranio.xyz/adrianbonpin/gridline" target="_blank" rel="noopener" class="ghost-btn"> <div class="flex flex-wrap items-center justify-center gap-3">
Star on Gitea <a href="https://git.ranio.xyz/adrianbonpin/gridline" target="_blank" rel="noopener" class="ghost-btn">
</a> Star on Gitea
</a>
<a href="https://ko-fi.com/adrianbonpin" target="_blank" rel="noopener" class="ghost-btn">
☕ Support on Ko-fi
</a>
</div>
<p class="mt-6 text-xs text-[var(--text-muted)] max-w-sm mx-auto">
Gridline stays free. No feature is ever behind a payment. Ko-fi is only for those who want to say thanks.
</p>
</div> </div>
</section> </section>