17 Commits
Author SHA1 Message Date
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
adrianbonpin 8bcdc0888f chore: release 0.7.14 (#22)
Release / linux (push) Successful in 16m24s
Release / windows (push) Successful in 18m27s
2026-09-04 03:44:32 +00:00
adrianbonpin fd451d219d feat: pin keychain item ACLs to the app's code signature (macOS) (#21) 2026-09-04 03:41:54 +00:00
adrianbonpin a86f5f7199 docs: Homebrew install via Gitea tap (#20) 2026-09-04 02:54:08 +00:00
adrianbonpin 2fccb7b405 fix: use non-deprecated depends_on macos syntax in cask (#19) 2026-09-04 02:53:33 +00:00
adrianbonpin 0efabbdc2e fix: point Homebrew cask at Gitea releases (#18) 2026-09-04 02:46:06 +00:00
adrianbonpin 294e72e6ff chore: drop xattr postflight from Homebrew cask template (#17) 2026-09-04 02:32:43 +00:00
adrianbonpin 15f4dc9bac chore: sync Cargo.lock version to 0.7.13 (#16) 2026-09-04 02:03:13 +00:00
adrianbonpin 867df98958 fix: sign bundled DB tools with Developer ID for notarization (#15) 2026-09-04 02:02:54 +00:00
adrianbonpin e66d7ac102 release: v0.7.13 — Developer-ID signed + notarized macOS (#14)
Release / windows (push) Successful in 19m32s
Release / linux (push) Successful in 22m48s
2026-09-04 01:43:08 +00:00
adrianbonpin 84a6f8d41c feat: Developer-ID sign + notarize macOS builds (#13) 2026-09-04 01:40:48 +00:00
adrianbonpin 08b0f79f07 feat(www): SEO technical + on-page improvements (#12) 2026-09-02 09:10:50 +00:00
adrianbonpin 9611567daa chore: sync Cargo.lock to v0.7.12 + gitignore bundled DB tools (#11) 2026-09-02 08:45:26 +00:00
adrianbonpin 0bc5110e05 fix(www): point download buttons at Gitea + v0.7.12 (#10) 2026-09-02 08:43:31 +00:00
adrianbonpin 2433697951 fix(www): point download buttons at Gitea + v0.7.12 (#9) 2026-09-01 06:15:43 +00:00
45 changed files with 1092 additions and 393 deletions
+5 -10
View File
@@ -7,16 +7,11 @@ name: Release
# git checkout prod && git pull # git checkout prod && git pull
# git tag v0.5.0 && git push origin v0.5.0 # git tag v0.5.0 && git push origin v0.5.0
# #
# SIGNING STATUS: macOS bundles are AD-HOC signed via tauri.conf.json # SIGNING STATUS: macOS is Developer-ID signed + notarized via
# (bundle.macOS.signingIdentity: "-", hardenedRuntime: false). That replaces # scripts/release-mac.sh (built manually on Adrian's machine). NOTE: this
# the linker-only signature that made macOS report the app as "damaged" and # GitHub workflow is NOT the active release path — Gitea Actions uses
# refuse Finder launches — users now get the standard "Open Anyway" prompt # .gitea/workflows/release.yml (Linux/Windows only). Linux/Windows installers
# (or run `sudo xattr -dr com.apple.quarantine` for the damaged-error case, see # remain unsigned.
# README). Still NOT Developer-ID signed / notarized (no certs yet).
# tauri-action automatically signs + notarizes when the signing secrets are
# present, so the moment we add APPLE_CERTIFICATE / APPLE_API_KEY /
# WINDOWS_CERTIFICATE (or Azure Trusted Signing) to repo secrets, future
# builds are properly signed — no changes to this file required.
on: on:
push: push:
+10
View File
@@ -38,3 +38,13 @@ docs/superpowers/
.env .env
!.env.example !.env.example
.worktrees/ .worktrees/
# Bundled DB client tools (built/downloaded during release, never committed)
desktop/src-tauri/resources/pg_tools/pg_dump
desktop/src-tauri/resources/pg_tools/pg_restore
desktop/src-tauri/resources/pg_tools/psql
desktop/src-tauri/resources/pg_tools/libpq.5.dylib
desktop/src-tauri/resources/mysql_tools/mariadb
desktop/src-tauri/resources/mysql_tools/mariadb-dump
desktop/src-tauri/resources/mysql_tools/libcrypto.3.dylib
desktop/src-tauri/resources/mysql_tools/libssl.3.dylib
+5 -5
View File
@@ -184,7 +184,7 @@ Cut a release by tagging the **`prod`** branch once the PR is merged — `git ta
- `desktop/src/lib/version.test.ts` and `desktop/src/lib/docs-coverage.test.ts` if they assert the version - `desktop/src/lib/version.test.ts` and `desktop/src/lib/docs-coverage.test.ts` if they assert the version
- **README download links are static (versioned)** — both download tables (top **Download** section + **Which file should I download?**) link directly to the release-tag assets (`releases/download/v0.7.9/<file>`). tauri-action uses default versioned asset names (`Gridline_<ver>_aarch64.dmg`, `Gridline-<ver>-1.x86_64.rpm`, etc.) — update BOTH tables to the new names on every release (see the MAINTENANCE comment in README.md). - **README download links are static (versioned)** — both download tables (top **Download** section + **Which file should I download?**) link directly to the release-tag assets (`releases/download/v0.7.9/<file>`). tauri-action uses default versioned asset names (`Gridline_<ver>_aarch64.dmg`, `Gridline-<ver>-1.x86_64.rpm`, etc.) — update BOTH tables to the new names on every release (see the MAINTENANCE comment in README.md).
- **Bundled pg tools:** `tauri.conf.json` `bundle.resources` lists `resources/pg_tools/*`; the `release.yml` matrix builds/downloads + checksum-verifies the static binaries before the Tauri build step. - **Bundled pg tools:** `tauri.conf.json` `bundle.resources` lists `resources/pg_tools/*`; the `release.yml` matrix builds/downloads + checksum-verifies the static binaries before the Tauri build step.
- **Release notes must include the macOS first-launch instructions** — until a Developer ID signing certificate is secured, releases are ad-hoc signed only (not notarized), so every macOS user hits a Gatekeeper prompt. The draft release body must include: right-click → **Open** → **Open** (or System Settings → Privacy & Security → **Open Anyway**) for "developer cannot be verified", and `sudo xattr -dr com.apple.quarantine /Applications/Gridline.app` (with the re-run-after-every-upgrade note) for "damaged and can't be opened". - **macOS is Developer-ID signed + notarized** via `scripts/release-mac.sh` (built manually on Adrian's machine; not in CI). The script auto-detects the Developer ID identity from the login keychain, reads notarization credentials from `~/.config/gridline/notarize.env` (App Store Connect API key), and verifies codesign + stapled notarization before uploading. If the cert or credentials are missing it fails with setup instructions (see the README's "macOS signing & notarization" section).
### Adding a Tauri Command ### Adding a Tauri Command
@@ -244,7 +244,7 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
| DB Viewer: SQLite browse + query | ✅ | Full support via rusqlite | | DB Viewer: SQLite browse + query | ✅ | Full support via rusqlite |
| DB Viewer: MySQL browse + query + edit | ✅ | Full viewer: connect (SSL + SSH tunnel), databases/tables/columns/FKs, query + pagination, inline cell editing + changes queue, DDL copy (`SHOW CREATE TABLE`), CSV/JSON import — added in v0.7.0. PK-only editing (no ctid equivalent); VARBINARY `information_schema` columns decoded correctly | | DB Viewer: MySQL browse + query + edit | ✅ | Full viewer: connect (SSL + SSH tunnel), databases/tables/columns/FKs, query + pagination, inline cell editing + changes queue, DDL copy (`SHOW CREATE TABLE`), CSV/JSON import — added in v0.7.0. PK-only editing (no ctid equivalent); VARBINARY `information_schema` columns decoded correctly |
| DB Viewer: Redis browse | ❌ | Connection + test only; browsing gated off with a clean "not supported" state (v0.7.0) | | DB Viewer: Redis browse | ❌ | Connection + test only; browsing gated off with a clean "not supported" state (v0.7.0) |
| Password storage in OS keychain | ✅ | macOS Keychain, Linux Secret Service, Windows Credential Manager | | Password storage in OS keychain | ✅ | macOS Keychain, Linux Secret Service, Windows Credential Manager; macOS items get an explicit `SecAccess` ACL pinning them to the app's code-signature requirement on every save (`keychain_acl.rs`) — items survive re-signs/upgrades without keychain permission prompts |
| Enable keychain toggle | ✅ | Default ON (opt-out); OFF = don't persist the DB password (session-only, re-prompt on connect) + purge existing keychain entry; SSH secrets stay keychain-only (v0.7.6) | | Enable keychain toggle | ✅ | Default ON (opt-out); OFF = don't persist the DB password (session-only, re-prompt on connect) + purge existing keychain entry; SSH secrets stay keychain-only (v0.7.6) |
| SSH tunnel config UI | ✅ | Host, port, user, auth method, key path, passphrase fields | | SSH tunnel config UI | ✅ | Host, port, user, auth method, key path, passphrase fields |
| SSH tunnel runtime | ✅ | Real ssh2 tunnel (password + key auth), binds 127.0.0.1 only, secrets in OS keychain (`ssh_password:<id>` / `ssh_passphrase:<id>`), closed on pool eviction / app exit; TLS downgraded to `require` through the tunnel | | SSH tunnel runtime | ✅ | Real ssh2 tunnel (password + key auth), binds 127.0.0.1 only, secrets in OS keychain (`ssh_password:<id>` / `ssh_passphrase:<id>`), closed on pool eviction / app exit; TLS downgraded to `require` through the tunnel |
@@ -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 |
+45 -33
View File
@@ -30,31 +30,34 @@
## 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.12**): 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.12_aarch64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_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.12_x64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_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.12_x64-setup.exe](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_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.12_amd64.deb](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_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.12-1.x86_64.rpm](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline-0.7.12-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.12_amd64.AppImage](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_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):
```bash ```bash
brew install --cask AdrianBonpin/gridline/gridline # one-time setup (the tap is hosted on Gitea, not GitHub):
# or, after a one-time `brew tap AdrianBonpin/gridline`: brew tap AdrianBonpin/gridline https://git.ranio.xyz/adrianbonpin/homebrew-gridline.git
brew trust adrianbonpin/gridline
# install / upgrade:
brew install --cask gridline brew install --cask gridline
``` ```
The cask strips the macOS quarantine flag automatically during install, so **no manual `xattr` step is needed** on the Homebrew path — the app just launches. (Direct-DMG downloads still need the [first-launch instructions](#installers-are-unsigned-for-now) below.) The cask installs the app directly. The DMGs are Developer-ID signed and notarized, so macOS trusts them on first launch — no `xattr` or "Open Anyway" step needed.
> **macOS first launch:** macOS may say *"Gridline is damaged and can't be opened"* or *"the developer cannot be verified"* — this is expected; the app isn't Developer-ID signed/notarized yet. See the [macOS first-launch instructions](#installers-are-unsigned-for-now) (right-click → Open, or the one-time `xattr` fix). Not sure if your Mac is Intel or Apple Silicon? See [Which file should I download?](#which-file-should-i-download) below. > **macOS first launch:** 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.12 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:
@@ -99,6 +102,12 @@ 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.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.12** — **Reliable cell editing for non-text columns**: editing a PostgreSQL cell whose type isn't `text` (integers, booleans, UUIDs, `json`/`jsonb`, timestamps, enums, numerics, …) previously failed with a Rust serialization error; edited values are now sent to PostgreSQL in text wire format so the server parses them into the column's own type. **Recent connections fix**: the Recent strip no longer renders empty on first launch when the connections list hadn't loaded yet. **Full CI release pipeline for Windows**: self-hosted Windows runner builds the `.exe`/`.msi` installers (bundled `pg_dump`/`pg_restore`/`psql` + MariaDB clients) and uploads them to the release alongside Linux; version bump. - **v0.7.12** — **Reliable cell editing for non-text columns**: editing a PostgreSQL cell whose type isn't `text` (integers, booleans, UUIDs, `json`/`jsonb`, timestamps, enums, numerics, …) previously failed with a Rust serialization error; edited values are now sent to PostgreSQL in text wire format so the server parses them into the column's own type. **Recent connections fix**: the Recent strip no longer renders empty on first launch when the connections list hadn't loaded yet. **Full CI release pipeline for Windows**: self-hosted Windows runner builds the `.exe`/`.msi` installers (bundled `pg_dump`/`pg_restore`/`psql` + MariaDB clients) and uploads them to the release alongside Linux; version bump.
- **v0.7.10** — **Copy connection URL**: from a connection card's ⋮ menu, copy the connection string for use in env vars / other tools — **Copy connection URL** (includes the password, fetched from the OS keychain on demand) and **Copy connection URL (no password)** (safe to share). Builds `postgresql://` / `mysql://` / `sqlite://` / `redis://` strings with percent-encoded credentials and the PG `sslmode` appended; version bump. - **v0.7.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.
@@ -307,37 +316,40 @@ Pre-built installers for macOS, Windows, and Linux are published on the [Release
> ⚠️ Gridline is under active development. Expect rough edges and please [open issues](https://git.ranio.xyz/adrianbonpin/gridline/issues/new) when you hit them. > ⚠️ Gridline is under active development. Expect rough edges and please [open issues](https://git.ranio.xyz/adrianbonpin/gridline/issues/new) when you hit them.
#### Installers are unsigned (for now) #### macOS signing & notarization
Gridline is currently distributed **unsigned** — it doesn't pay for code-signing certificates yet (macOS builds are *ad-hoc signed*, so they pass Apple Silicon's launch checks but aren't Developer-ID signed or notarized). Your OS will warn you the first time you open it. This is expected — the app is safe, it just hasn't paid the signing fee: Gridline's macOS builds are **Developer-ID signed and notarized**, so macOS opens them without a Gatekeeper prompt. This is configured on the machine that runs `scripts/release-mac.sh` (macOS is built manually, not in CI).
> **Homebrew users:** the [Homebrew cask](#download) strips the quarantine flag automatically during install, so you skip all of the steps below — the app just launches. **One-time setup** (only the repo owner needs to do this):
- **macOS:** if you see *"Gridline can't be opened because the developer cannot be verified"* (or the app simply won't open from Finder), right-click the app → **Open** → **Open** (or System Settings → Privacy & Security → **Open Anyway**). Do this once per version. 1. **Create a Developer ID Application certificate** — [developer.apple.com](https://developer.apple.com) → Certificates, Identifiers & Profiles → **+** → **Developer ID Application**. Generate a Certificate Signing Request from Keychain Access (Certificate Assistant → *Request a Certificate From a Certificate Authority*), upload it, download the `.cer`, and double-click to install into your login keychain. Verify with `security find-identity -v -p codesigning`.
- **macOS — "Gridline is damaged and can't be opened":** remove the quarantine flag macOS attaches to downloaded apps, then launch normally: 2. **Create an App Store Connect API key** for notarization — [appstoreconnect.apple.com](https://appstoreconnect.apple.com) → Users and Access → **Integrations** → **App Store Connect API** → **+** (name it, *Developer* access). Download the `.p8` (shown once) and note the **Key ID** and **Issuer ID**.
3. **Save the notarization credentials** to `~/.config/gridline/notarize.env` (never committed):
```bash ```bash
sudo xattr -dr com.apple.quarantine /Applications/Gridline.app APPLE_API_KEY=<Key ID>
``` APPLE_API_ISSUER=<Issuer ID>
APPLE_API_KEY_PATH=/absolute/path/to/AuthKey_<KeyID>.p8
```
(`sudo` is required — some files inside the bundle are read-only, so removing the flag needs admin rights.) Re-run after **every upgrade** — each freshly-downloaded copy gets re-quarantined. (Alternatively, drop the `.p8` at `~/.appstoreconnect/private_keys/AuthKey_<KeyID>.p8` and omit `APPLE_API_KEY_PATH`.)
- **Windows:** on the SmartScreen prompt, click **More info** → **Run anyway**.
- **Linux:** no warning — install and run normally.
Code signing **will be added in the future** (Apple Developer Program + a Windows signing cert, e.g. Azure Trusted Signing) — the CI workflow is already wired to pick up the signing secrets automatically the moment they exist, no workflow changes needed. `scripts/release-mac.sh` auto-detects the Developer ID identity from your keychain, signs both architectures, notarizes + staples them, and verifies the result before uploading. If the certificate or credentials are missing it fails with instructions (set `GRIDLINE_SKIP_NOTARIZE=1` to build signed-but-unnotarized for testing only).
**Windows** still shows a SmartScreen prompt (no Windows signing cert yet); **Linux** installs without a warning.
#### 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.12**): 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.12_aarch64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_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.12_x64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_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.12_x64-setup.exe](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_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.12_amd64.deb](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_amd64.deb) | Install: `sudo apt install ./Gridline_0.7.12_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.12-1.x86_64.rpm](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline-0.7.12-1.x86_64.rpm) | Install: `sudo dnf install Gridline-0.7.12-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.12_amd64.AppImage](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_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.
@@ -347,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.12 git tag v0.7.15
git push origin v0.7.12 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**.
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "gridline-desktop", "name": "gridline-desktop",
"private": true, "private": true,
"version": "0.7.12", "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": {
+4 -1
View File
@@ -1783,10 +1783,11 @@ dependencies = [
[[package]] [[package]]
name = "gridline" name = "gridline"
version = "0.7.11" version = "0.7.15"
dependencies = [ dependencies = [
"bytes", "bytes",
"chrono", "chrono",
"core-foundation 0.10.1",
"deadpool-postgres", "deadpool-postgres",
"indexmap 2.14.0", "indexmap 2.14.0",
"redis", "redis",
@@ -1795,6 +1796,8 @@ dependencies = [
"rustls-native-certs", "rustls-native-certs",
"rustls-pemfile 2.2.0", "rustls-pemfile 2.2.0",
"rustls-pki-types", "rustls-pki-types",
"security-framework 3.7.0",
"security-framework-sys",
"serde", "serde",
"serde_json", "serde_json",
"sqlx", "sqlx",
+10 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "gridline" name = "gridline"
version = "0.7.12" 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"
@@ -49,3 +49,12 @@ rustls-pemfile = "2"
rustls-pki-types = "1" rustls-pki-types = "1"
rustls-native-certs = "0.7" rustls-native-certs = "0.7"
# Keychain ACL normalization (explicit SecAccess on stored secrets so items
# survive app re-signs without permission prompts) — macOS only.
[target.'cfg(target_os = "macos")'.dependencies]
security-framework = "3.7"
security-framework-sys = "2.17"
# core-foundation: CFString/CFArray/TCFType helpers for the SecAccess FFI
# (already a transitive dep of security-framework; pinned to the same 0.10 line)
core-foundation = "0.10"
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Required for the WKWebView (JIT-compiled JavaScript). Without this the
app crashes under the hardened runtime. -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<!-- The WebView allocates executable memory for JIT; allow it. -->
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<!-- Tauri's dev server / debug builds rely on DYLD_* environment variables. -->
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
</dict>
</plist>
@@ -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]
+11 -3
View File
@@ -1,3 +1,4 @@
use crate::keychain_acl;
use tauri_plugin_keyring_store::KeyringExt; use tauri_plugin_keyring_store::KeyringExt;
/// Store a connection password in the OS keychain. /// Store a connection password in the OS keychain.
@@ -11,7 +12,10 @@ pub fn save_connection_password(
app.keyring() app.keyring()
.store .store
.set_password(&connection_id, &password) .set_password(&connection_id, &password)
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())?;
// Best-effort: pin the item's ACL to this app so re-signed builds never prompt.
keychain_acl::after_store(&app, &connection_id);
Ok(())
} }
/// Retrieve a connection password from the OS keychain. /// Retrieve a connection password from the OS keychain.
@@ -55,7 +59,9 @@ pub fn save_connection_ssh_password(
app.keyring() app.keyring()
.store .store
.set_password(&ssh_account("password", &connection_id), &password) .set_password(&ssh_account("password", &connection_id), &password)
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())?;
keychain_acl::after_store(&app, &ssh_account("password", &connection_id));
Ok(())
} }
/// Retrieve an SSH tunnel password from the OS keychain. /// Retrieve an SSH tunnel password from the OS keychain.
@@ -93,7 +99,9 @@ pub fn save_connection_ssh_passphrase(
app.keyring() app.keyring()
.store .store
.set_password(&ssh_account("passphrase", &connection_id), &passphrase) .set_password(&ssh_account("passphrase", &connection_id), &passphrase)
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())?;
keychain_acl::after_store(&app, &ssh_account("passphrase", &connection_id));
Ok(())
} }
/// Retrieve an SSH private-key passphrase from the OS keychain. /// Retrieve an SSH private-key passphrase from the OS keychain.
+198
View File
@@ -0,0 +1,198 @@
//! Keychain ACL normalization for stored secrets (macOS).
//!
//! # Why this exists
//!
//! Generic-password items stored via the legacy keychain API get a default ACL
//! that grants access to "the application that created the item". When a Tauri
//! app is rebuilt, its code signature changes (dev builds are ad-hoc signed, so
//! the identity differs on every build), and the new binary no longer matches
//! the item's ACL — macOS then shows the "Gridline wants to access your
//! keychain" prompt.
//!
//! The fix: after storing a secret, replace the item's ACL with an explicit
//! `SecAccess` whose trusted-application list contains exactly *this app*,
//! resolved by `SecTrustedApplicationCreateFromPath(NULL)` — the current
//! process's code-signature requirement. For a Developer ID-signed release
//! build that requirement is stable across versions (anchored to the signing
//! certificate), so items stay accessible without prompts through upgrades.
//!
//! # Prompt behavior
//!
//! - Item created by this build → the ACL set is silent.
//! - Item created by an older/differently-signed build → macOS shows the
//! standard one-time authorization dialog; "Always Allow" replaces the ACL
//! and the prompt never reappears (the same dialog the app would show on the
//! first read anyway — this just makes the result permanent and clean).
//!
//! All operations are best-effort: `after_store` swallows errors so keychain
//! writes never fail because of ACL normalization.
/// Best-effort ACL normalization after a successful keychain write.
///
/// Resolves the service name the same way `tauri-plugin-keyring-store` does
/// (the Tauri bundle identifier) and never fails the caller's operation.
pub fn after_store(app: &tauri::AppHandle, account: &str) {
let service = app.config().identifier.clone();
if let Err(e) = ensure_item_acl(&service, account) {
eprintln!("[keychain_acl] skipped for {account}: {e}");
}
}
/// Grant this app (by its code-signature requirement) access to a keychain item.
#[cfg(target_os = "macos")]
pub fn ensure_item_acl(service: &str, account: &str) -> Result<(), String> {
use core_foundation::array::CFArray;
use core_foundation::base::{CFTypeRef, TCFType};
use core_foundation::string::CFString;
use security_framework::item::{ItemClass, ItemSearchOptions, Reference, SearchResult};
use security_framework::os::macos::access::SecAccess;
use security_framework_sys::base::SecAccessRef;
// Locate the item WITHOUT reading its secret: a ref-only lookup
// (`kSecReturnRef`, no data/attributes) never triggers an authorization
// prompt, so merely probing an item we can't yet access is safe.
let found = ItemSearchOptions::new()
.class(ItemClass::generic_password())
.service(service)
.account(account)
.load_refs(true)
.load_data(false)
.load_attributes(false)
.search()
.map_err(|e| format!("keychain item lookup failed: {e}"))?;
let item = match found.first() {
Some(SearchResult::Ref(Reference::KeychainItem(item))) => item,
// No item (yet) or an unexpected result shape — nothing to normalize.
_ => return Ok(()),
};
fn cvt(status: i32) -> Result<(), String> {
if status == 0 {
Ok(())
} else {
Err(format!("security framework error {status}"))
}
}
// Build an access object whose trusted list is exactly this app. A NULL
// path resolves the trusted application to the current process's code
// signature requirement.
unsafe {
let mut trusted_app_ref: *mut OpaqueSecTrustedApplication = std::ptr::null_mut();
cvt(SecTrustedApplicationCreateFromPath(std::ptr::null(), &mut trusted_app_ref))?;
let trusted_app = SecTrustedApplication::wrap_under_create_rule(trusted_app_ref);
let trusted_list = CFArray::from_CFTypes(&[trusted_app]);
let descriptor = CFString::new("Gridline");
let mut access_ref: SecAccessRef = std::ptr::null_mut();
cvt(SecAccessCreate(
descriptor.as_concrete_TypeRef() as CFTypeRef,
trusted_list.as_concrete_TypeRef() as CFTypeRef,
&mut access_ref,
))?;
let access = SecAccess::wrap_under_create_rule(access_ref);
// Replace the item's ACL. Silent when we already own the item;
// one-time authorization dialog when the item was created by a
// differently-signed build.
cvt(SecKeychainItemSetAccess(
item.as_concrete_TypeRef(),
access.as_concrete_TypeRef(),
))
}
}
/// Non-macOS platforms have no keychain ACL concept — nothing to do.
#[cfg(not(target_os = "macos"))]
pub fn ensure_item_acl(_service: &str, _account: &str) -> Result<(), String> {
Ok(())
}
#[cfg(target_os = "macos")]
mod macos_ffi {
use core_foundation::base::{CFTypeRef, CFTypeID};
/// Minimal `TCFType` wrapper for `SecTrustedApplication` — the
/// security-framework crate exposes no high-level API for it.
#[repr(C)]
pub struct OpaqueSecTrustedApplication {
_priv: [u8; 0],
}
pub type SecTrustedApplicationRef = *mut OpaqueSecTrustedApplication;
use core_foundation::{declare_TCFType, impl_TCFType};
declare_TCFType!(SecTrustedApplication, SecTrustedApplicationRef);
impl_TCFType!(
SecTrustedApplication,
SecTrustedApplicationRef,
SecTrustedApplicationGetTypeID
);
unsafe impl Send for SecTrustedApplication {}
unsafe impl Sync for SecTrustedApplication {}
extern "C" {
// SecAccess/SecTrustedApplication APIs are deprecated in the macOS SDK
// but remain fully functional (the legacy keychain store this app uses
// via tauri-plugin-keyring-store relies on the same API family).
pub fn SecTrustedApplicationGetTypeID() -> CFTypeID;
pub fn SecTrustedApplicationCreateFromPath(
path: CFTypeRef,
app: *mut SecTrustedApplicationRef,
) -> i32;
pub fn SecAccessCreate(
descriptor: CFTypeRef,
trustedlist: CFTypeRef,
access: *mut security_framework_sys::base::SecAccessRef,
) -> i32;
pub fn SecKeychainItemSetAccess(
item: security_framework_sys::base::SecKeychainItemRef,
access: security_framework_sys::base::SecAccessRef,
) -> i32;
}
}
#[cfg(target_os = "macos")]
use macos_ffi::{
OpaqueSecTrustedApplication, SecAccessCreate, SecKeychainItemSetAccess, SecTrustedApplication,
SecTrustedApplicationCreateFromPath,
};
#[cfg(all(test, target_os = "macos"))]
mod tests {
use super::*;
use security_framework::os::macos::keychain::SecKeychain;
use security_framework::os::macos::passwords::find_generic_password;
/// Real-keychain integration test: creates a generic-password item the
/// same way the keyring store does, normalizes its ACL, verifies the
/// secret is still readable, and cleans up.
///
/// Ignored by default (`cargo test`) so CI/dev runs never touch the login
/// keychain. Run explicitly with:
/// `cargo test -- --ignored keychain_acl`
#[test]
#[ignore = "touches the real login keychain"]
fn keychain_acl_normalizes_item() {
let keychain = SecKeychain::default().expect("default keychain");
let service = "com.adrianbonpin.gridline.acl-test";
let account = format!("acl-test-{}", std::process::id());
// Create the item exactly like apple-native-keyring-store does
// (legacy SecKeychain generic-password API).
keychain
.set_generic_password(service, &account, b"test-secret")
.expect("create test item");
// ACL normalization must succeed on an item we just created.
ensure_item_acl(service, &account).expect("normalize ACL");
// The item must remain readable with the correct secret.
let (password, item) =
find_generic_password(Some(&[keychain]), service, &account).expect("read back");
assert_eq!(password.as_ref(), b"test-secret");
// Cleanup: delete the test item.
item.delete();
}
}
+1
View File
@@ -5,6 +5,7 @@
mod cancel; mod cancel;
mod commands; mod commands;
mod db; mod db;
mod keychain_acl;
mod models; mod models;
mod store; mod store;
+3 -2
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.12", "version": "0.7.15",
"identifier": "com.adrianbonpin.gridline", "identifier": "com.adrianbonpin.gridline",
"build": { "build": {
"beforeDevCommand": "bun run dev", "beforeDevCommand": "bun run dev",
@@ -29,7 +29,8 @@
"resources": ["resources/pg_tools/*", "resources/mysql_tools/*"], "resources": ["resources/pg_tools/*", "resources/mysql_tools/*"],
"macOS": { "macOS": {
"signingIdentity": "-", "signingIdentity": "-",
"hardenedRuntime": false "hardenedRuntime": true,
"entitlements": "entitlements.plist"
}, },
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
@@ -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.12)", () => { 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.12", () => { it("version is 0.7.15", () => {
expect(tauriConf.version).toBe("0.7.12"); 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": {
+7 -7
View File
@@ -4,7 +4,7 @@ import { describe, it, expect } from "vitest";
import agents from "../../../AGENTS.md?raw"; import agents from "../../../AGENTS.md?raw";
import readme from "../../../README.md?raw"; import readme from "../../../README.md?raw";
describe("v0.7.12 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 +24,8 @@ describe("v0.7.12 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.12", () => { it("README declares v0.7.15", () => {
expect(readme).toContain("0.7.12"); 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 +64,9 @@ describe("v0.7.12 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.12 assets in both download tables", () => { it("README links to v0.7.15 assets in both download tables", () => {
expect(readme).toContain("releases/download/v0.7.12/"); expect(readme).toContain("releases/download/v0.7.15/");
expect(readme).toContain("Gridline_0.7.12_aarch64.dmg"); expect(readme).toContain("Gridline_0.7.15_aarch64.dmg");
expect(readme).toContain("Gridline-0.7.12-1.x86_64.rpm"); expect(readme).toContain("Gridline-0.7.15-1.x86_64.rpm");
}); });
}); });
+2 -2
View File
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
import pkg from "../../package.json"; import pkg from "../../package.json";
describe("version", () => { describe("version", () => {
it("declares v0.7.12 across the app shell", () => { it("declares v0.7.15 across the app shell", () => {
expect(pkg.version).toBe("0.7.12"); 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) =>
+5 -19
View File
@@ -11,13 +11,8 @@
# #
# brew install --cask gridline # brew install --cask gridline
# #
# QUARANTINE: Gridline is ad-hoc signed (not Developer-ID signed/notarized), # SIGNING: Gridline is Developer-ID signed + notarized, so macOS trusts it
# so Homebrew casks (which deliberately set com.apple.quarantine on the # and no quarantine-stripping postflight is needed.
# downloaded artifact) would make Gatekeeper refuse to launch it. The
# `postflight` block strips the quarantine flag automatically — the standard
# GoReleaser-documented pattern for unsigned casks — so `brew install` works
# with no manual `xattr` step. This is a Gatekeeper bypass; drop the
# postflight once the app is properly signed + notarized.
cask "gridline" do cask "gridline" do
arch arm: "aarch64", intel: "x64" arch arm: "aarch64", intel: "x64"
@@ -26,24 +21,15 @@ cask "gridline" do
sha256 arm: "@ARM_SHA256@", sha256 arm: "@ARM_SHA256@",
intel: "@INTEL_SHA256@" intel: "@INTEL_SHA256@"
url "https://github.com/AdrianBonpin/gridline/releases/download/v#{version}/Gridline_#{version}_#{arch}.dmg" url "https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v#{version}/Gridline_#{version}_#{arch}.dmg"
name "Gridline" name "Gridline"
desc "Open-source, cross-platform database GUI client for PostgreSQL" desc "Open-source, cross-platform database GUI client for PostgreSQL"
homepage "https://github.com/AdrianBonpin/gridline" homepage "https://git.ranio.xyz/adrianbonpin/gridline"
depends_on macos: ">= :monterey" depends_on macos: :monterey
app "Gridline.app" app "Gridline.app"
# The "installation step" — strip the quarantine flag so the unsigned
# (ad-hoc signed) app launches without the Gatekeeper prompt.
postflight do
if OS.mac?
system_command "/usr/bin/xattr",
args: ["-dr", "com.apple.quarantine", "#{appdir}/Gridline.app"]
end
end
zap trash: [ zap trash: [
"~/Library/Application Support/com.adrianbonpin.gridline", "~/Library/Application Support/com.adrianbonpin.gridline",
"~/Library/Caches/com.adrianbonpin.gridline", "~/Library/Caches/com.adrianbonpin.gridline",
+96 -3
View File
@@ -45,12 +45,76 @@ get_token() {
if [ -n "$tok" ]; then echo "$tok"; return; fi if [ -n "$tok" ]; then echo "$tok"; return; fi
fi fi
fi fi
read -rsp "Gitea personal access token (repo write): " GITEA_TOKEN # Fall back to the git credential store: tea's OAuth login stores an access
echo "$GITEA_TOKEN" # token as the password for every push to this remote, and Gitea accepts it
# for API auth (Authorization: token <access_token>).
local host cred
host="${GITEA_SERVER#*://}"
cred="$(printf 'protocol=https\nhost=%s\n\n' "$host" \
| git credential fill 2>/dev/null | sed -n 's/^password=//p' | head -1 || true)"
if [ -n "$cred" ]; then echo "$cred"; return; fi
# Fail fast — continuing without a token fails only at the upload step,
# after the whole build has run.
echo "error: no Gitea token found" >&2
echo " set GITEA_TOKEN, or run 'tea login' / 'git push' once so the" >&2
echo " credential store has an entry for $host" >&2
exit 1
} }
GITEA_TOKEN="$(get_token)" GITEA_TOKEN="$(get_token)"
# ---- macOS signing + notarization -----------------------------------------
# Requires a "Developer ID Application" certificate in the login keychain and
# an App Store Connect API key for notarization. See the README's "macOS
# signing" section for how to create both.
#
# Notarization credentials are read from ~/.config/gridline/notarize.env
# (never committed). Create it with:
# APPLE_API_KEY=<Key ID>
# APPLE_API_ISSUER=<Issuer ID>
# APPLE_API_KEY_PATH=/absolute/path/to/AuthKey_<KeyID>.p8
# (or drop the .p8 at ~/.appstoreconnect/private_keys/AuthKey_<KeyID>.p8 and
# omit APPLE_API_KEY_PATH — tauri auto-searches that location.)
# 1. Resolve the Developer ID Application signing identity from the keychain.
SIGNING_IDENTITY="$(security find-identity -v -p codesigning 2>/dev/null \
| grep -i 'Developer ID Application' \
| sed -E 's/.*"([^"]+)".*/\1/' \
| head -1 || true)"
if [ -z "$SIGNING_IDENTITY" ]; then
echo "ERROR: No 'Developer ID Application' certificate found in the login keychain." >&2
echo " Create one at https://developer.apple.com/account/resources/certificates/list" >&2
echo " (Certificates, IDs & Profiles -> + -> Developer ID Application), download the" >&2
echo " .cer, and double-click it to install into your login keychain." >&2
echo " Verify with: security find-identity -v -p codesigning" >&2
exit 1
fi
export APPLE_SIGNING_IDENTITY="$SIGNING_IDENTITY"
echo ">> Signing identity: $SIGNING_IDENTITY"
# 2. Load notarization credentials.
NOTARIZE_ENV="${HOME}/.config/gridline/notarize.env"
if [ -f "$NOTARIZE_ENV" ]; then
set -a
# shellcheck disable=SC1090
source "$NOTARIZE_ENV"
set +a
fi
if [ -z "${APPLE_API_KEY:-}" ] || [ -z "${APPLE_API_ISSUER:-}" ]; then
if [ "${GRIDLINE_SKIP_NOTARIZE:-0}" = "1" ]; then
echo ">> WARNING: notarization credentials missing; GRIDLINE_SKIP_NOTARIZE=1 set." >&2
echo " Building signed but NOT notarized (test-only — users will still see a prompt)." >&2
else
echo "ERROR: Notarization credentials not configured." >&2
echo " Create ${NOTARIZE_ENV} with APPLE_API_KEY and APPLE_API_ISSUER" >&2
echo " (and APPLE_API_KEY_PATH, or place the .p8 at ~/.appstoreconnect/private_keys/)." >&2
echo " See the README's 'macOS signing' section for the App Store Connect API key setup." >&2
exit 1
fi
else
echo ">> Notarization: API key ${APPLE_API_KEY} (issuer ${APPLE_API_ISSUER})"
fi
# ---- build bundled DB tools (macOS) ---------------------------------------- # ---- build bundled DB tools (macOS) ----------------------------------------
WORKSPACE="$(cd "$(dirname "$0")/.." && pwd)" WORKSPACE="$(cd "$(dirname "$0")/.." && pwd)"
OUT_PG="${WORKSPACE}/desktop/src-tauri/resources/pg_tools" OUT_PG="${WORKSPACE}/desktop/src-tauri/resources/pg_tools"
@@ -60,6 +124,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"
@@ -75,10 +141,19 @@ for b in pg_dump pg_restore psql; do
libpq=$(otool -L "$OUT_PG/$b" | awk '/libpq/ {print $1; exit}') libpq=$(otool -L "$OUT_PG/$b" | awk '/libpq/ {print $1; exit}')
install_name_tool -change "$libpq" "@loader_path/libpq.5.dylib" "$OUT_PG/$b" install_name_tool -change "$libpq" "@loader_path/libpq.5.dylib" "$OUT_PG/$b"
done done
# install_name_tool invalidates the linker's signature; re-sign with the
# Developer ID identity + hardened runtime + secure timestamp so the bundle
# passes notarization (Apple rejects ad-hoc-signed nested executables).
for f in "$OUT_PG"/pg_dump "$OUT_PG"/pg_restore "$OUT_PG"/psql "$OUT_PG"/libpq.5.dylib; do
codesign --force --options runtime --timestamp --sign "$SIGNING_IDENTITY" "$f"
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
@@ -112,7 +187,7 @@ for pass in 1 2 3 4 5 6; do
if [ ! -f "$OUT_MY/$base" ]; then cp "$lib" "$OUT_MY/$base" 2>/dev/null || true; fi if [ ! -f "$OUT_MY/$base" ]; then cp "$lib" "$OUT_MY/$base" 2>/dev/null || true; fi
install_name_tool -change "$lib" "@loader_path/$base" "$dylib" 2>/dev/null || true install_name_tool -change "$lib" "@loader_path/$base" "$dylib" 2>/dev/null || true
done || true done || true
codesign --force --sign - "$dylib" 2>/dev/null || true codesign --force --options runtime --timestamp --sign "$SIGNING_IDENTITY" "$dylib"
done done
done done
@@ -130,6 +205,24 @@ VERSION="${TAG#v}"
BUNDLE_ARM="${WORKSPACE}/desktop/src-tauri/target/aarch64-apple-darwin/release/bundle" BUNDLE_ARM="${WORKSPACE}/desktop/src-tauri/target/aarch64-apple-darwin/release/bundle"
BUNDLE_INTEL="${WORKSPACE}/desktop/src-tauri/target/x86_64-apple-darwin/release/bundle" BUNDLE_INTEL="${WORKSPACE}/desktop/src-tauri/target/x86_64-apple-darwin/release/bundle"
# ---- verify signing + notarization -----------------------------------------
# Confirm both .app bundles are properly signed and carry a stapled
# notarization ticket before we upload them. spctl can lag right after
# notarization (assessment cache), so stapler validate is the authoritative
# check; a pending spctl is only a warning.
for APP in \
"${BUNDLE_ARM}/macos/Gridline.app" \
"${BUNDLE_INTEL}/macos/Gridline.app"; do
[ -d "$APP" ] || { echo "missing $APP" >&2; exit 1; }
echo ">> Verifying $APP"
codesign --verify --deep --strict --verbose=2 "$APP" \
|| { echo "codesign verify FAILED" >&2; exit 1; }
xcrun stapler validate "$APP" \
|| { echo "notarization ticket not stapled" >&2; exit 1; }
spctl --assess --type execute --verbose=4 "$APP" \
|| echo " (spctl assessment pending — normal right after notarization; stapler validate passed)"
done
# ---- create/ensure release + upload DMGs ----------------------------------- # ---- create/ensure release + upload DMGs -----------------------------------
API="${GITEA_SERVER}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}" API="${GITEA_SERVER}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}"
AUTH="Authorization: token ${GITEA_TOKEN}" AUTH="Authorization: token ${GITEA_TOKEN}"
+1 -3
View File
@@ -10,9 +10,7 @@ export default defineConfig({
compressHTML: true, compressHTML: true,
trailingSlash: 'ignore', trailingSlash: 'ignore',
integrations: [ integrations: [
sitemap({ sitemap(),
lastmod: new Date(),
}),
react(), react(),
], ],
vite: { vite: {
+1
View File
@@ -0,0 +1 @@
/* /404.html 404
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

+5 -22
View File
@@ -59,18 +59,15 @@
const isMobile = /Mobi|Android|iPhone|iPad|iPod/i.test(userAgent); const isMobile = /Mobi|Android|iPhone|iPad|iPod/i.test(userAgent);
const keepLabel = downloadBtn.hasAttribute("data-download-label"); const keepLabel = downloadBtn.hasAttribute("data-download-label");
const releasesUrl = "https://github.com/AdrianBonpin/gridline/releases"; const releasesUrl = "https://git.ranio.xyz/adrianbonpin/gridline/releases";
const version = "0.7.10"; const version = "0.7.15";
if (isMobile) { if (isMobile) {
downloadBtn.href = releasesUrl; downloadBtn.href = releasesUrl;
if (!keepLabel) downloadBtn.textContent = "Get it on GitHub"; if (!keepLabel) downloadBtn.textContent = "Get it on Gitea";
} else if (isMac) { } else if (isMac) {
downloadBtn.href = `${releasesUrl}/download/v${version}/Gridline_${version}_aarch64.dmg`; downloadBtn.href = `${releasesUrl}/download/v${version}/Gridline_${version}_aarch64.dmg`;
if (!keepLabel) downloadBtn.textContent = "Download for macOS"; if (!keepLabel) downloadBtn.textContent = "Download for macOS";
if (downloadBtn.id === "download") {
document.getElementById("macos-hint")?.classList.remove("hidden");
}
} else if (isWin) { } else if (isWin) {
downloadBtn.href = `${releasesUrl}/download/v${version}/Gridline_${version}_x64-setup.exe`; downloadBtn.href = `${releasesUrl}/download/v${version}/Gridline_${version}_x64-setup.exe`;
if (!keepLabel) downloadBtn.textContent = "Download for Windows"; if (!keepLabel) downloadBtn.textContent = "Download for Windows";
@@ -83,18 +80,6 @@
} }
}); });
document.getElementById("copy-quarantine")?.addEventListener("click", async () => {
const command = "sudo xattr -dr com.apple.quarantine /Applications/Gridline.app";
try {
await navigator.clipboard.writeText(command);
const btn = document.getElementById("copy-quarantine");
if (!btn) return;
const original = btn.textContent;
btn.textContent = "Copied";
setTimeout(() => (btn.textContent = original), 1500);
} catch {}
});
// Nav scroll background // Nav scroll background
const nav = document.getElementById("nav"); const nav = document.getElementById("nav");
function onScroll() { function onScroll() {
@@ -141,24 +126,22 @@
const subtitle = hero.querySelector(".hero-animate > p:nth-of-type(2)"); const subtitle = hero.querySelector(".hero-animate > p:nth-of-type(2)");
const buttons = hero.querySelector(".hero-animate > div"); const buttons = hero.querySelector(".hero-animate > div");
const screenshot = hero.querySelector(".hero-screenshot"); const screenshot = hero.querySelector(".hero-screenshot");
const macosHint = hero.querySelector("#macos-hint");
if (reducedMotion) { if (reducedMotion) {
// Reduced motion: reveal the hero immediately (CSS hides it at first // Reduced motion: reveal the hero immediately (CSS hides it at first
// paint to avoid flicker, but the timeline below is skipped). Section // paint to avoid flicker, but the timeline below is skipped). Section
// reveals below still run through the IntersectionObserver. // reveals below still run through the IntersectionObserver.
anime.set([eyebrow, h1, subtitle, buttons, screenshot, macosHint], { opacity: 1, translateY: 0 }); anime.set([eyebrow, h1, subtitle, buttons, screenshot], { opacity: 1, translateY: 0 });
} else { } else {
const heroTl = anime.timeline({ easing: "easeOutExpo" }); const heroTl = anime.timeline({ easing: "easeOutExpo" });
anime.set([eyebrow, h1, subtitle, buttons, screenshot, macosHint], { opacity: 0, translateY: 20 }); anime.set([eyebrow, h1, subtitle, buttons, screenshot], { opacity: 0, translateY: 20 });
anime.set(screenshot, { opacity: 0, scale: 0.96, translateY: 0 }); anime.set(screenshot, { opacity: 0, scale: 0.96, translateY: 0 });
if (eyebrow) heroTl.add({ targets: eyebrow, opacity: [0, 1], translateY: [15, 0] }, 0); if (eyebrow) heroTl.add({ targets: eyebrow, opacity: [0, 1], translateY: [15, 0] }, 0);
if (h1) heroTl.add({ targets: h1, opacity: [0, 1], translateY: [30, 0], duration: 800 }, 100); if (h1) heroTl.add({ targets: h1, opacity: [0, 1], translateY: [30, 0], duration: 800 }, 100);
if (subtitle) heroTl.add({ targets: subtitle, opacity: [0, 1], translateY: [20, 0] }, 250); if (subtitle) heroTl.add({ targets: subtitle, opacity: [0, 1], translateY: [20, 0] }, 250);
if (buttons) heroTl.add({ targets: buttons, opacity: [0, 1], translateY: [20, 0] }, 350); if (buttons) heroTl.add({ targets: buttons, opacity: [0, 1], translateY: [20, 0] }, 350);
if (macosHint) heroTl.add({ targets: macosHint, opacity: [0, 1], translateY: [20, 0] }, 400);
if (screenshot) heroTl.add({ targets: screenshot, opacity: [0, 1], scale: [0.96, 1], duration: 900 }, 450); if (screenshot) heroTl.add({ targets: screenshot, opacity: [0, 1], scale: [0.96, 1], duration: 900 }, 450);
} }
} }
+6
View File
@@ -12,6 +12,12 @@
"sizes": "any", "sizes": "any",
"type": "image/svg+xml", "type": "image/svg+xml",
"purpose": "any" "purpose": "any"
},
{
"src": "/apple-touch-icon.png",
"sizes": "180x180",
"type": "image/png",
"purpose": "any"
} }
] ]
} }
+34
View File
@@ -5,6 +5,40 @@ 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",
date: "2026-09-04",
highlights: [
"No more repeated macOS keychain prompts — saved connection passwords and SSH secrets are now ACL-pinned to the app's code signature when stored, so app upgrades never re-trigger the macOS keychain access dialog (users upgrading from older builds see one final prompt — choose Always Allow).",
"Install via Homebrew — add the Gitea tap (brew tap AdrianBonpin/gridline https://git.ranio.xyz/adrianbonpin/homebrew-gridline.git) and run brew install --cask gridline.",
],
},
{
version: "0.7.13",
date: "2026-09-04",
highlights: [
"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.",
],
},
{
version: "0.7.12",
date: "2026-09-01",
highlights: [
"Reliable cell editing for non-text columns — editing a PostgreSQL cell whose type isn't text (integers, booleans, UUIDs, json/jsonb, timestamps, enums, numerics) now works; edited values are sent to PostgreSQL in text wire format so the server parses them into the column's own type.",
"Recent connections fix — the Recent strip no longer renders empty on first launch when the connections list hadn't loaded yet.",
"Full CI release pipeline for Windows — the self-hosted Windows runner now builds the .exe/.msi installers (bundled pg_dump/pg_restore/psql + MariaDB clients) and uploads them to the release alongside Linux.",
],
},
{ {
version: "0.7.10", version: "0.7.10",
date: "2026-08-13", date: "2026-08-13",
+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.",
}, },
+11 -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 = [],
@@ -36,8 +36,13 @@ const isProd = import.meta.env.PROD;
<link rel="canonical" href={canonicalURL} /> <link rel="canonical" href={canonicalURL} />
<link rel="sitemap" href="/sitemap-index.xml" /> <link rel="sitemap" href="/sitemap-index.xml" />
<link rel="icon" type="image/svg+xml" href="/gridline-icon.svg" /> <link rel="icon" type="image/svg+xml" href="/gridline-icon.svg" />
<link rel="apple-touch-icon" href="/gridline-icon.svg" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" /> <link rel="manifest" href="/site.webmanifest" />
<link rel="preload" as="image" href="/og.png" />
<link rel="preconnect" href="https://stat.ranio.xyz" crossorigin />
<link rel="preconnect" href="https://git.ranio.xyz" />
<link rel="dns-prefetch" href="https://stat.ranio.xyz" />
<link rel="dns-prefetch" href="https://git.ranio.xyz" />
<meta property="og:title" content={title} /> <meta property="og:title" content={title} />
<meta property="og:description" content={description} /> <meta property="og:description" content={description} />
@@ -46,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" />
@@ -54,6 +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:site" content="@AdrianBonpin" /> <meta name="twitter:site" content="@AdrianBonpin" />
<meta name="twitter:creator" content="@AdrianBonpin" /> <meta name="twitter:creator" content="@AdrianBonpin" />
@@ -143,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>
+15
View File
@@ -6,6 +6,7 @@ import {
buildWebSiteJsonLd, buildWebSiteJsonLd,
buildBreadcrumbJsonLd, buildBreadcrumbJsonLd,
buildTitle, buildTitle,
buildProductJsonLd,
} from "./seo"; } from "./seo";
describe("seo builders", () => { describe("seo builders", () => {
@@ -70,4 +71,18 @@ describe("seo builders", () => {
it("buildTitle appends the brand", () => { it("buildTitle appends the brand", () => {
expect(buildTitle("PostgreSQL GUI")).toBe("PostgreSQL GUI | Gridline"); expect(buildTitle("PostgreSQL GUI")).toBe("PostgreSQL GUI | Gridline");
}); });
it("buildProductJsonLd returns valid Product schema", () => {
const ld = buildProductJsonLd({
name: "Gridline",
url: "https://getgridline.app/",
description: "Open-source database GUI",
image: "https://getgridline.app/og.png",
offersPrice: 0,
offersCurrency: "USD",
});
expect(ld["@type"]).toBe("Product");
expect(ld.name).toBe("Gridline");
expect((ld.offers as { price: number }).price).toBe(0);
});
}); });
+23
View File
@@ -108,3 +108,26 @@ export function buildBreadcrumbJsonLd(
export function buildTitle(primary: string, brand = "Gridline"): string { export function buildTitle(primary: string, brand = "Gridline"): string {
return `${primary} | ${brand}`; return `${primary} | ${brand}`;
} }
export function buildProductJsonLd(opts: {
name: string;
url: string;
description: string;
image: string;
offersPrice: number;
offersCurrency: string;
}): JsonLd {
return {
"@context": "https://schema.org",
"@type": "Product",
name: opts.name,
url: opts.url,
description: opts.description,
image: opts.image,
offers: {
"@type": "Offer",
price: opts.offersPrice,
priceCurrency: opts.offersCurrency,
},
};
}
+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.";
--- ---
+29 -15
View File
@@ -6,6 +6,8 @@ import {
buildFaqJsonLd, buildFaqJsonLd,
buildOrganizationJsonLd, buildOrganizationJsonLd,
buildWebSiteJsonLd, buildWebSiteJsonLd,
buildProductJsonLd,
buildBreadcrumbJsonLd,
} from "../lib/seo"; } from "../lib/seo";
import { Image } from "astro:assets"; import { Image } from "astro:assets";
import HeroDbViewer from "../components/hero/HeroDbViewer"; import HeroDbViewer from "../components/hero/HeroDbViewer";
@@ -13,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.12"; const version = "0.7.15";
const site = "https://getgridline.app"; const site = "https://getgridline.app";
const jsonLd = [ const jsonLd = [
buildSoftwareApplicationJsonLd({ buildSoftwareApplicationJsonLd({
@@ -42,9 +44,21 @@ const jsonLd = [
name: "Gridline", name: "Gridline",
url: `${site}/`, url: `${site}/`,
}), }),
buildProductJsonLd({
name: "Gridline",
url: `${site}/`,
description:
"The open-source database GUI for PostgreSQL, MySQL, SQLite, and Redis. No paywalls, no tab limits, fully open source.",
image: `${site}/og.png`,
offersPrice: 0,
offersCurrency: "USD",
}),
buildBreadcrumbJsonLd([
{ name: "Home", url: `${site}/` },
]),
]; ];
--- ---
<Layout title="Gridline — 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 -->
@@ -56,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>
@@ -66,14 +80,6 @@ const jsonLd = [
View on Gitea View on Gitea
</a> </a>
</div> </div>
<div id="macos-hint" class="hidden max-w-lg mx-auto w-full rounded-xl border border-[var(--border)] bg-[var(--surface)] p-3 text-left mt-4">
<p class="text-xs text-[var(--text-muted)] mb-2">First launch on macOS? Run this in Terminal:</p>
<div class="flex items-center justify-between gap-3 rounded-lg bg-[var(--bg)] border border-[var(--border)] px-3 py-2">
<code class="font-mono text-xs truncate">sudo xattr -dr com.apple.quarantine /Applications/Gridline.app</code>
<button id="copy-quarantine" class="text-xs font-medium text-[var(--primary)] hover:text-[var(--primary-hover)] transition-colors shrink-0" aria-label="Copy command">Copy</button>
</div>
</div>
</div> </div>
<div class="hero-screenshot mt-14 md:mt-20 mx-auto max-w-6xl"> <div class="hero-screenshot mt-14 md:mt-20 mx-auto max-w-6xl">
@@ -170,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>