6 Commits
38 changed files with 388 additions and 1005 deletions
+10 -5
View File
@@ -7,11 +7,16 @@ name: Release
# git checkout prod && git pull
# git tag v0.5.0 && git push origin v0.5.0
#
# SIGNING STATUS: macOS is Developer-ID signed + notarized via
# scripts/release-mac.sh (built manually on Adrian's machine). NOTE: this
# GitHub workflow is NOT the active release path — Gitea Actions uses
# .gitea/workflows/release.yml (Linux/Windows only). Linux/Windows installers
# remain unsigned.
# SIGNING STATUS: macOS bundles are AD-HOC signed via tauri.conf.json
# (bundle.macOS.signingIdentity: "-", hardenedRuntime: false). That replaces
# the linker-only signature that made macOS report the app as "damaged" and
# refuse Finder launches — users now get the standard "Open Anyway" prompt
# (or run `sudo xattr -dr com.apple.quarantine` for the damaged-error case, see
# 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:
push:
+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
- **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.
- **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).
- **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".
### 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: MySQL browse + query + edit | ✅ | Full viewer: connect (SSL + SSH tunnel), databases/tables/columns/FKs, query + pagination, inline cell editing + changes queue, DDL copy (`SHOW CREATE TABLE`), CSV/JSON import — added in v0.7.0. PK-only editing (no ctid equivalent); VARBINARY `information_schema` columns decoded correctly |
| DB Viewer: Redis browse | ❌ | Connection + test only; browsing gated off with a clean "not supported" state (v0.7.0) |
| Password storage in OS keychain | ✅ | macOS Keychain, Linux Secret Service, Windows Credential Manager; 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 |
| Password storage in OS keychain | ✅ | macOS Keychain, Linux Secret Service, Windows Credential Manager |
| 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 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 |
| 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 |
| 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) |
| Visual filter builder | ✅ | Drag-and-drop column palette (@dnd-kit) with type-aware operators (textish → contains, else eq), AND semantics, persists in tab `filterRules` |
| Column sorting (server-side) | ✅ | Multi-column asc/desc pushed to SQL ORDER BY |
| Column show/hide | ✅ | Toggle visibility per column |
| 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) |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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); `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. |
| 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. |
| 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** |
| 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 |
+31 -43
View File
@@ -30,34 +30,31 @@
## 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.15**):
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**):
| OS | Architecture | Download |
| :--------------------------- | :--------------------------- | :----------------------------------------------------------------------------------------------------------------------------------- |
| **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.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.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.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.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.15_amd64.AppImage](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.15/Gridline_0.7.15_amd64.AppImage) |
| **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** | Intel | [Gridline_0.7.12_x64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_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) |
| **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) |
| **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) |
| **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) |
**macOS via Homebrew** (recommended for Mac users):
```bash
# one-time setup (the tap is hosted on Gitea, not GitHub):
brew tap AdrianBonpin/gridline https://git.ranio.xyz/adrianbonpin/homebrew-gridline.git
brew trust adrianbonpin/gridline
# install / upgrade:
brew install --cask AdrianBonpin/gridline/gridline
# or, after a one-time `brew tap AdrianBonpin/gridline`:
brew install --cask gridline
```
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.
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.)
> **macOS first launch:** Gridline is Developer-ID signed and notarized, so macOS opens it without a Gatekeeper prompt. Not sure if your Mac is Intel or Apple Silicon? See [Which file should I download?](#which-file-should-i-download) below.
> **macOS first launch:** 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.
<!--
MAINTENANCE: These links are STATIC (versioned) — they point at the v0.7.15
MAINTENANCE: These links are STATIC (versioned) — they point at the v0.7.12
release assets, not at a moving "latest" target. On every new release,
update BOTH tables here (Download + Which file should I download?) to the
new version's asset names, which are tauri-action's default naming:
@@ -102,12 +99,6 @@ Gridline is built for developers and small teams who manage multiple database en
<details>
<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.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.
@@ -316,40 +307,37 @@ 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.
#### macOS signing & notarization
#### Installers are unsigned (for now)
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).
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:
**One-time setup** (only the repo owner needs to do this):
> **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.
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`.
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):
- **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.
- **macOS — "Gridline is damaged and can't be opened":** remove the quarantine flag macOS attaches to downloaded apps, then launch normally:
```bash
APPLE_API_KEY=<Key ID>
APPLE_API_ISSUER=<Issuer ID>
APPLE_API_KEY_PATH=/absolute/path/to/AuthKey_<KeyID>.p8
sudo xattr -dr com.apple.quarantine /Applications/Gridline.app
```
(Alternatively, drop the `.p8` at `~/.appstoreconnect/private_keys/AuthKey_<KeyID>.p8` and omit `APPLE_API_KEY_PATH`.)
(`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.
- **Windows:** on the SmartScreen prompt, click **More info** → **Run anyway**.
- **Linux:** no warning — install and run normally.
`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.
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.
#### 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.15**):
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**):
| Your system | Download this | Notes |
| :------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------- |
| 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.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.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.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.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.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 |
| 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 **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 |
| **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) |
| **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` |
| **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` |
| **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 |
**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.
@@ -359,8 +347,8 @@ Cutting a release is one command — CI builds everything. **Releases are cut fr
```bash
git checkout prod && git pull
git tag v0.7.15
git push origin v0.7.15
git tag v0.7.12
git push origin v0.7.12
```
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",
"private": true,
"version": "0.7.15",
"version": "0.7.12",
"description": "An open-source, high-performance database GUI client for PostgreSQL and beyond",
"type": "module",
"scripts": {
+1 -4
View File
@@ -1783,11 +1783,10 @@ dependencies = [
[[package]]
name = "gridline"
version = "0.7.15"
version = "0.7.12"
dependencies = [
"bytes",
"chrono",
"core-foundation 0.10.1",
"deadpool-postgres",
"indexmap 2.14.0",
"redis",
@@ -1796,8 +1795,6 @@ dependencies = [
"rustls-native-certs",
"rustls-pemfile 2.2.0",
"rustls-pki-types",
"security-framework 3.7.0",
"security-framework-sys",
"serde",
"serde_json",
"sqlx",
+1 -10
View File
@@ -1,6 +1,6 @@
[package]
name = "gridline"
version = "0.7.15"
version = "0.7.12"
description = "An open-source, high-performance database GUI client for PostgreSQL and beyond"
authors = ["you"]
edition = "2021"
@@ -49,12 +49,3 @@ rustls-pemfile = "2"
rustls-pki-types = "1"
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
@@ -1,16 +0,0 @@
<?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,14 +566,6 @@ pub fn build_pg_insert_sql(
table: &str,
columns: &[(String, 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 mut params: Vec<serde_json::Value> = Vec::new();
let placeholders: Vec<String> = columns
@@ -3110,15 +3102,6 @@ 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
/// from `sqlite_master`.
#[test]
+3 -11
View File
@@ -1,4 +1,3 @@
use crate::keychain_acl;
use tauri_plugin_keyring_store::KeyringExt;
/// Store a connection password in the OS keychain.
@@ -12,10 +11,7 @@ pub fn save_connection_password(
app.keyring()
.store
.set_password(&connection_id, &password)
.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(())
.map_err(|e| e.to_string())
}
/// Retrieve a connection password from the OS keychain.
@@ -59,9 +55,7 @@ pub fn save_connection_ssh_password(
app.keyring()
.store
.set_password(&ssh_account("password", &connection_id), &password)
.map_err(|e| e.to_string())?;
keychain_acl::after_store(&app, &ssh_account("password", &connection_id));
Ok(())
.map_err(|e| e.to_string())
}
/// Retrieve an SSH tunnel password from the OS keychain.
@@ -99,9 +93,7 @@ pub fn save_connection_ssh_passphrase(
app.keyring()
.store
.set_password(&ssh_account("passphrase", &connection_id), &passphrase)
.map_err(|e| e.to_string())?;
keychain_acl::after_store(&app, &ssh_account("passphrase", &connection_id));
Ok(())
.map_err(|e| e.to_string())
}
/// Retrieve an SSH private-key passphrase from the OS keychain.
-198
View File
@@ -1,198 +0,0 @@
//! 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,7 +5,6 @@
mod cancel;
mod commands;
mod db;
mod keychain_acl;
mod models;
mod store;
+2 -3
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Gridline",
"version": "0.7.15",
"version": "0.7.12",
"identifier": "com.adrianbonpin.gridline",
"build": {
"beforeDevCommand": "bun run dev",
@@ -29,8 +29,7 @@
"resources": ["resources/pg_tools/*", "resources/mysql_tools/*"],
"macOS": {
"signingIdentity": "-",
"hardenedRuntime": true,
"entitlements": "entitlements.plist"
"hardenedRuntime": false
},
"icon": [
"icons/32x32.png",
@@ -231,7 +231,6 @@ export function DbViewerScreen({
const changesQueue = useDbViewerStore((s) => s.changesQueue);
const tables = useDbViewerStore((s) => s.tables);
const stageCellEdit = useDbViewerStore((s) => s.stageCellEdit);
const updateChange = useDbViewerStore((s) => s.updateChange);
const isRunning = activeTab?.tabType === "query" && !!activeTab?.loading;
@@ -340,19 +339,6 @@ export function DbViewerScreen({
[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) => {
setRowDetailIdx(rowIndex);
}, []);
@@ -846,24 +832,6 @@ export function DbViewerScreen({
// Data is already filtered and sorted server-side; no client-side transform needed.
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(
(e: React.MouseEvent) => {
e.preventDefault();
@@ -1216,7 +1184,7 @@ const onQueriesPanelResizeStart = useCallback(
connectionId={connectionId}
schema={activeSchema}
table={activeTable}
rows={displayRows}
rows={processedRows}
columns={columns}
hiddenColumns={hiddenColumns}
selectedRows={selectedRows}
@@ -1224,7 +1192,6 @@ const onQueriesPanelResizeStart = useCallback(
tabType={activeTab?.tabType ?? "table"}
getLocator={getLocator}
onStageEdit={readOnlyTable ? undefined : handleStageEdit}
onStageInsertCell={readOnlyTable ? undefined : handleStageInsertCell}
onOpenRowDetail={handleOpenRowDetail}
readOnly={readOnlyTable}
enumValues={editorOptions?.enums}
@@ -1232,7 +1199,6 @@ const onQueriesPanelResizeStart = useCallback(
fkPlaceholders={editorOptions?.fkPlaceholders}
stagedValues={stagedValues}
pendingKeys={pendingKeys}
pendingInsertChangeIds={pendingInsertChangeIds}
onToggleRow={(rowIndex) => {
setSelectedRows(
(prev) => {
@@ -1382,7 +1348,7 @@ const onQueriesPanelResizeStart = useCallback(
connectionId={connectionId}
schema={activeSchema}
table={activeTable}
rows={displayRows}
rows={processedRows}
columns={columns}
hiddenColumns={hiddenColumns}
selectedRows={selectedRows}
@@ -1390,7 +1356,6 @@ const onQueriesPanelResizeStart = useCallback(
tabType={activeTab?.tabType ?? "table"}
getLocator={getLocator}
onStageEdit={readOnlyTable ? undefined : handleStageEdit}
onStageInsertCell={readOnlyTable ? undefined : handleStageInsertCell}
onOpenRowDetail={handleOpenRowDetail}
readOnly={readOnlyTable}
enumValues={editorOptions?.enums}
@@ -1398,7 +1363,6 @@ const onQueriesPanelResizeStart = useCallback(
fkPlaceholders={editorOptions?.fkPlaceholders}
stagedValues={stagedValues}
pendingKeys={pendingKeys}
pendingInsertChangeIds={pendingInsertChangeIds}
onToggleRow={(rowIndex) => {
setSelectedRows((prev) => {
const next = new Set(
@@ -0,0 +1,27 @@
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([]);
});
});
@@ -0,0 +1,132 @@
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";
return (
<div className="relative flex items-center gap-1" ref={menuRef}>
<div className="flex items-center gap-1" ref={menuRef}>
<button
aria-label="New schema"
onClick={() => {
@@ -123,7 +123,7 @@ export function SchemaMenu({ connectionId, schema, onRefresh }: SchemaMenuProps)
)}
{menuOpen && (
<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">
<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">
<button
type="button"
onClick={() => {
@@ -325,115 +325,13 @@ describe("TableControls", () => {
expect(screen.queryByLabelText(/insert row/i)).toBeNull();
});
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", () => {
it("renders the FilterBuilder inside the filter popover", () => {
seed([makeTab()], "tab-1");
renderControls();
fireEvent.click(screen.getByLabelText(/column filters/i));
expect(screen.getByText("Column Filters")).toBeInTheDocument();
expect(screen.getByText("+ Add filter")).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();
expect(
screen.getByText("Drop columns here to add filters"),
).toBeInTheDocument();
});
it("export dropdown includes the Excel option", () => {
@@ -6,6 +6,7 @@ import {
} from "lucide-react";
import { useDbViewerStore, type FilterRule, type SortRule } from "../../stores/dbViewerStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { FilterBuilder } from "./FilterBuilder";
import { Tooltip } from "../ui/Tooltip";
import { exportData } from "../../lib/exportData";
import type { ColumnInfo } from "../../lib/types";
@@ -48,28 +49,22 @@ function DropdownMenu({
open,
setOpen,
align,
wrapRef,
children,
}: {
open: boolean;
setOpen: (v: boolean) => void;
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;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const close = (e: MouseEvent) => {
const container = wrapRef?.current ?? ref.current;
if (container && !container.contains(e.target as Node)) setOpen(false);
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, [open, setOpen, wrapRef]);
}, [open, setOpen]);
if (!open) return null;
return (
@@ -84,84 +79,28 @@ 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({
columns,
rules,
onChange,
open,
setOpen,
wrapRef,
}: {
columns: ColumnInfo[];
rules: FilterRule[];
onChange: (rules: FilterRule[]) => void;
open: boolean;
setOpen: (v: boolean) => void;
wrapRef: React.RefObject<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.
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const close = (e: MouseEvent) => {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
setOpen(false);
}
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, [open, setOpen, wrapRef]);
}, [open, setOpen]);
if (!open) return null;
@@ -177,13 +116,17 @@ function FilterModal({
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
return (
<div className="absolute top-full left-0 mt-1 z-30 w-96 rounded-lg bg-surface border border-border shadow-lg p-3">
<div
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">
<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">
<X size={14} />
</button>
</div>
<FilterBuilder columns={columns} rules={rules} onChange={onChange} />
{rules.map((rule) => (
<div key={rule.id} className="flex items-center gap-1.5 mb-1.5">
<select
@@ -209,10 +152,12 @@ function FilterModal({
<option value="notnull">not null</option>
</select>
{rule.operator !== "null" && rule.operator !== "notnull" && (
<DebouncedValueInput
<input
type="text"
value={rule.value}
onCommit={(v) => updateRule(rule.id, { value: v })}
onChange={(e) => updateRule(rule.id, { value: e.target.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">
@@ -237,27 +182,22 @@ function SortModal({
onChange,
open,
setOpen,
wrapRef,
}: {
columns: ColumnInfo[];
rules: SortRule[];
onChange: (rules: SortRule[]) => void;
open: boolean;
setOpen: (v: boolean) => void;
wrapRef: React.RefObject<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.
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const close = (e: MouseEvent) => {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
setOpen(false);
}
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, [open, setOpen, wrapRef]);
}, [open, setOpen]);
if (!open) return null;
@@ -273,7 +213,10 @@ function SortModal({
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
return (
<div className="absolute top-full left-0 mt-1 z-30 w-72 rounded-lg bg-surface border border-border shadow-lg p-3">
<div
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">
<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">
@@ -329,7 +272,6 @@ function BulkActionsDropdown({
onClearSelection: () => void;
}) {
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
const addChange = useDbViewerStore((s) => s.addChange);
const copyToClipboard = (text: string) => {
@@ -396,7 +338,7 @@ function BulkActionsDropdown({
};
return (
<div className="relative" ref={wrapRef}>
<div className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
@@ -405,7 +347,7 @@ function BulkActionsDropdown({
<span className="text-xs font-medium">Actions</span>
<ChevronDown size={12} />
</button>
<DropdownMenu open={open} setOpen={setOpen} align="right" wrapRef={wrapRef}>
<DropdownMenu open={open} setOpen={setOpen} align="right">
<button
type="button"
onClick={handleCopyJSON}
@@ -503,16 +445,11 @@ export function TableControls({
// local state
const [filterOpen, setFilterOpen] = useState(false);
const filterWrapRef = useRef<HTMLDivElement>(null);
const [sortOpen, setSortOpen] = useState(false);
const sortWrapRef = useRef<HTMLDivElement>(null);
const [columnMenuOpen, setColumnMenuOpen] = useState(false);
const columnMenuWrapRef = useRef<HTMLDivElement>(null);
const [exportOpen, setExportOpen] = useState(false);
const exportWrapRef = useRef<HTMLDivElement>(null);
const [autoRefresh, setAutoRefresh] = useState(defaultRefreshRate);
const [autoRefreshOpen, setAutoRefreshOpen] = useState(false);
const autoRefreshWrapRef = useRef<HTMLDivElement>(null);
// 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),
@@ -547,14 +484,8 @@ export function TableControls({
};
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> = {};
columns.forEach((c) => {
if (c.editable && !c.is_pk && !c.is_generated) newData[c.name] = null;
});
columns.forEach((c) => { newData[c.name] = null; });
addChange({
type: "insert",
schema,
@@ -614,7 +545,7 @@ export function TableControls({
);
const exportControl = (
<div className="relative" ref={exportWrapRef}>
<div className="relative">
<Tooltip content="Export" side="bottom">
<button
type="button"
@@ -625,7 +556,7 @@ export function TableControls({
<Download size={14} />
</button>
</Tooltip>
<DropdownMenu open={exportOpen} setOpen={setExportOpen} wrapRef={exportWrapRef}>
<DropdownMenu open={exportOpen} setOpen={setExportOpen}>
{EXPORT_FORMATS.map((fmt) => (
<button
key={fmt.ext}
@@ -641,7 +572,7 @@ export function TableControls({
);
const columnsControl = (
<div className="relative" ref={columnMenuWrapRef}>
<div className="relative">
<Tooltip content="Show/hide columns" side="bottom">
<button
type="button"
@@ -656,7 +587,6 @@ export function TableControls({
open={columnMenuOpen}
setOpen={setColumnMenuOpen}
align={isQuery ? "left" : "right"}
wrapRef={columnMenuWrapRef}
>
<div className="px-2 py-1 text-[10px] text-text-muted uppercase tracking-wider">
Visible columns
@@ -728,7 +658,7 @@ export function TableControls({
{refreshControl}
{/* Auto-refresh */}
<div className="relative" ref={autoRefreshWrapRef}>
<div className="relative">
<Tooltip content={`Auto-refresh: ${autoRefresh > 0 ? `${autoRefresh / 1000}s` : "Off"}`} side="bottom">
<button
type="button"
@@ -742,7 +672,7 @@ export function TableControls({
{autoRefresh > 0 && <span className="text-[10px] font-medium">{autoRefresh / 1000}s</span>}
</button>
</Tooltip>
<DropdownMenu open={autoRefreshOpen} setOpen={setAutoRefreshOpen} wrapRef={autoRefreshWrapRef}>
<DropdownMenu open={autoRefreshOpen} setOpen={setAutoRefreshOpen}>
{AUTO_REFRESH_OPTIONS.map((opt) => (
<button
key={opt.value}
@@ -762,7 +692,7 @@ export function TableControls({
<div className="w-px h-4 bg-border mx-1" />
{/* Filter */}
<div className="relative" ref={filterWrapRef}>
<div className="relative">
<Tooltip content="Column filters" side="bottom">
<button
type="button"
@@ -786,12 +716,11 @@ export function TableControls({
onChange={onFilterChange}
open={filterOpen}
setOpen={setFilterOpen}
wrapRef={filterWrapRef}
/>
</div>
{/* Sort */}
<div className="relative" ref={sortWrapRef}>
<div className="relative">
<Tooltip content="Sort rules" side="bottom">
<button
type="button"
@@ -815,7 +744,6 @@ export function TableControls({
onChange={onSortChange}
open={sortOpen}
setOpen={setSortOpen}
wrapRef={sortWrapRef}
/>
</div>
@@ -6,18 +6,16 @@ interface Props {
isJson: boolean;
isFk: 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;
onSetNull: () => void; onOpenFk: () => void; onClose: () => void;
onViewRow: () => void; onSelectRow: () => void;
}
export function CellContextMenu({ anchorRect, editable, isJson, isFk, nullable, isInsertRow = false, onCopy, onCopyJson, onEdit, onSetNull, onOpenFk, onClose, onViewRow, onSelectRow }: Props) {
export function CellContextMenu({ anchorRect, editable, isJson, isFk, nullable, onCopy, onCopyJson, onEdit, onSetNull, onOpenFk, onClose, onViewRow, onSelectRow }: Props) {
const items: { label: string; icon: React.ReactNode; onClick: () => void; show: boolean }[] = [
{ label: "Copy", icon: <Copy size={12} />, onClick: () => { onCopy(); onClose(); }, show: true },
{ label: "Copy JSON", icon: <Braces size={12} />, onClick: () => { onCopyJson(); onClose(); }, show: isJson },
{ label: "View Row", icon: <Eye size={12} />, onClick: () => { onViewRow(); onClose(); }, show: !isInsertRow },
{ label: "Select Row", icon: <MousePointerClick size={12} />, onClick: () => { onSelectRow(); onClose(); }, show: !isInsertRow },
{ label: "View Row", icon: <Eye size={12} />, onClick: () => { onViewRow(); onClose(); }, show: true },
{ label: "Select Row", icon: <MousePointerClick size={12} />, onClick: () => { onSelectRow(); onClose(); }, show: true },
{ 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: "Open FK reference", icon: <Link2 size={12} />, onClick: () => { onOpenFk(); onClose(); }, show: isFk },
@@ -13,42 +13,6 @@ describe("CellEditor", () => {
fireEvent.keyDown(input, { key: "Enter" });
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", () => {
const onCommit = vi.fn();
render(<CellEditor initialValue="Alice" dataType="text" onCommit={onCommit} onCancel={vi.fn()} nullable />);
@@ -76,7 +40,7 @@ describe("CellEditor", () => {
it("blocks empty commits on non-nullable non-text columns and shows an error", () => {
const onCommit = vi.fn();
render(<CellEditor initialValue="42" dataType="integer" onCommit={onCommit} onCancel={vi.fn()} />);
const input = screen.getByRole("spinbutton");
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onCommit).not.toHaveBeenCalled();
@@ -93,7 +57,7 @@ describe("CellEditor", () => {
it("clears the validation error as soon as the user types again", () => {
const onCommit = vi.fn();
render(<CellEditor initialValue="42" dataType="integer" onCommit={onCommit} onCancel={vi.fn()} />);
const input = screen.getByRole("spinbutton");
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(screen.getByTestId("cell-editor-error")).toBeInTheDocument();
+1 -103
View File
@@ -30,45 +30,6 @@ const controlClass =
/** Types that tolerate an empty string when NOT NULL ('' is a valid value). */
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({
initialValue,
dataType,
@@ -79,11 +40,7 @@ export function CellEditor({
onCommit,
onCancel,
}: CellEditorProps) {
const kind = inputKindForType(dataType);
const isInteger = INTEGER_TYPES.has(dataType.toLowerCase().trim());
const [value, setValue] = useState(
kind === "datetime" ? toDatetimeLocal(initialValue) : initialValue,
);
const [value, setValue] = useState(initialValue);
const [query, setQuery] = useState("");
const [error, setError] = useState<string | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
@@ -116,8 +73,6 @@ export function CellEditor({
useEffect(() => {
if (enumValues && enumValues.length > 0) {
enumRef.current?.focus();
} else if (kind === "boolean") {
enumRef.current?.focus();
} else if (fkOptions && fkOptions.length > 0) {
searchRef.current?.focus();
searchRef.current?.select();
@@ -303,63 +258,6 @@ 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
? `${inputClass} h-4 resize-none overflow-y-auto whitespace-pre leading-none py-0.5`
: inputClass;
@@ -48,21 +48,6 @@ describe("VirtualDataGrid", () => {
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", () => {
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
+16 -51
View File
@@ -45,10 +45,6 @@ interface VirtualDataGridProps {
stagedValues?: Record<string, string | null>;
/** Keys of cells with a PENDING (not yet committed) update → drives the pulsing orange outline. */
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;
@@ -78,24 +74,20 @@ export function VirtualDataGrid({
fkPlaceholders,
stagedValues,
pendingKeys,
pendingInsertChangeIds,
onStageInsertCell,
}: VirtualDataGridProps) {
const parentRef = useRef<HTMLDivElement>(null);
const visibleColumns = columns.filter((c) => !hiddenColumns.has(c.name));
const hasColumns = columns.length > 0;
const pendingInsertCount = pendingInsertChangeIds?.length ?? 0;
const realRowCount = rows.length - pendingInsertCount;
const allSelected = realRowCount > 0 && selectedRows.size === realRowCount;
const allSelected = rows.length > 0 && selectedRows.size === rows.length;
const selectAllRef = useRef<HTMLInputElement>(null);
// Indeterminate state for partial selection
useEffect(() => {
if (selectAllRef.current) {
selectAllRef.current.indeterminate = selectedRows.size > 0 && selectedRows.size < realRowCount;
selectAllRef.current.indeterminate = selectedRows.size > 0 && selectedRows.size < rows.length;
}
}, [selectedRows.size, realRowCount]);
}, [selectedRows.size, rows.length]);
const virtualizer = useVirtualizer({
count: rows.length,
@@ -290,8 +282,6 @@ export function VirtualDataGrid({
const isJson = !isNull && (col.data_type === "jsonb" || col.data_type === "json");
const jp = isJson ? jsonPreview(displayCell) : { label: "", isJson: false };
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 isEditing = editingCell?.row === rowIndex && editingCell?.col === colIndex;
const isPending =
@@ -317,9 +307,6 @@ export function VirtualDataGrid({
// revert/display stays correct even after repeated edits of the same cell.
const dbValue = ci >= 0 ? row[ci] : undefined;
if (committed !== (dbValue === null || dbValue === undefined ? null : dbValue)) {
if (isInsertRow && insertChangeId) {
onStageInsertCell?.(insertChangeId, col.name, committed);
} else {
const locator = getLocator?.(row) ?? {};
onStageEdit?.(
cellToUpdateChange({
@@ -338,7 +325,6 @@ export function VirtualDataGrid({
},
);
}
}
setPendingCellKey(cellKey);
setEditingCell(null);
};
@@ -369,9 +355,7 @@ export function VirtualDataGrid({
}
style={{ width: getWidth(col.name), flexShrink: 0 }}
title={
isInsertRow && !editable
? "auto (default)"
: isNull
isNull
? "NULL"
: isFk
? `FK → ${col.fk_ref![0]}.${col.fk_ref![1]}: ${String(displayCell)}`
@@ -406,8 +390,6 @@ export function VirtualDataGrid({
onCancel={() => setEditingCell(null)}
/>
</div>
) : isInsertRow && !editable ? (
<span className="italic text-text-muted/60">auto</span>
) : isNull ? (
<span className="italic text-text-muted">NULL</span>
) : isJson ? (
@@ -438,7 +420,7 @@ export function VirtualDataGrid({
</div>
);
},
[activeCell, columns, dbType, editingCell, enumValues, fkOptions, fkPlaceholders, getLocator, handleFkClick, onStageEdit, onStageInsertCell, schema, table, tabType, getWidth, pendingCell, stagedValues, pendingKeys, pendingCellKey, pendingInsertCount, pendingInsertChangeIds],
[activeCell, columns, dbType, editingCell, enumValues, fkOptions, fkPlaceholders, getLocator, handleFkClick, onStageEdit, schema, table, tabType, getWidth, pendingCell, stagedValues, pendingKeys, pendingCellKey],
);
// ── context menu helpers ──────────────────────────────
@@ -464,11 +446,6 @@ export function VirtualDataGrid({
const ci = columns.findIndex((c) => c.name === column.name);
const value = rows[row]?.[ci];
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]) ?? {};
onStageEdit?.(
cellToUpdateChange({
@@ -487,7 +464,7 @@ export function VirtualDataGrid({
},
);
},
[columns, dbType, getLocator, onStageEdit, onStageInsertCell, readOnly, rows, schema, table, tabType, visibleColumns, pendingInsertCount, pendingInsertChangeIds],
[columns, dbType, getLocator, onStageEdit, readOnly, rows, schema, table, tabType, visibleColumns],
);
return (
@@ -551,18 +528,15 @@ export function VirtualDataGrid({
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const gridIndex = virtualRow.index;
const isInsertRow = gridIndex < pendingInsertCount;
const realIndex = isInsertRow ? -1 : gridIndex - pendingInsertCount;
const row = rows[gridIndex];
const isSelected = !isInsertRow && selectedRows.has(realIndex);
const row = rows[virtualRow.index];
const isSelected = selectedRows.has(virtualRow.index);
return (
<div
key={virtualRow.key}
data-index={gridIndex}
data-index={virtualRow.index}
className={`flex items-center border-b border-border ${
isSelected ? "bg-accent/5" : ""
} ${isInsertRow ? "bg-accent/[0.03] ring-1 ring-inset ring-accent/60" : ""} hover:bg-surface/50`}
} hover:bg-surface/50`}
style={{
position: "absolute",
top: 0,
@@ -572,15 +546,7 @@ export function VirtualDataGrid({
transform: `translateY(${virtualRow.start}px)`,
}}
>
{hasColumns &&
(isInsertRow ? (
<div
style={{ width: 40, minWidth: 40 }}
className="flex items-center justify-center border-r border-border self-stretch"
>
<span className="text-[10px] text-accent/70" title="New row">+</span>
</div>
) : (
{hasColumns && (
<div
style={{ width: 40, minWidth: 40 }}
className="flex items-center justify-center border-r border-border self-stretch"
@@ -588,12 +554,12 @@ export function VirtualDataGrid({
<input
type="checkbox"
checked={isSelected}
onChange={() => onToggleRow(realIndex)}
onChange={() => onToggleRow(virtualRow.index)}
className="w-3.5 h-3.5 rounded border-border cursor-pointer accent-accent"
/>
</div>
))}
{visibleColumns.map((col, i) => renderCell(col, row, gridIndex, i))}
)}
{visibleColumns.map((col, i) => renderCell(col, row, virtualRow.index, i))}
</div>
);
})}
@@ -635,7 +601,6 @@ export function VirtualDataGrid({
isJson={ctxCol.data_type === "jsonb" || ctxCol.data_type === "json"}
isFk={ctxCol.is_fk && ctxCol.fk_ref != null}
nullable={ctxCol.is_nullable}
isInsertRow={ctxMenu.row < pendingInsertCount}
onCopy={() => {
void copyCellValue(ctxMenu.row, ctxMenu.col);
setCtxMenu(null);
@@ -645,11 +610,11 @@ export function VirtualDataGrid({
setCtxMenu(null);
}}
onViewRow={() => {
onOpenRowDetail?.(ctxMenu.row - pendingInsertCount);
onOpenRowDetail?.(ctxMenu.row);
setCtxMenu(null);
}}
onSelectRow={() => {
onToggleRow(ctxMenu.row - pendingInsertCount);
onToggleRow(ctxMenu.row);
setCtxMenu(null);
}}
onEdit={() => {
+3 -3
View File
@@ -1,11 +1,11 @@
import { describe, it, expect } from "vitest";
import tauriConf from "../../src-tauri/tauri.conf.json";
describe("tauri bundle config (v0.7.15)", () => {
describe("tauri bundle config (v0.7.12)", () => {
it("declares bundled pg_tools resources", () => {
expect(tauriConf.bundle.resources).toContain("resources/pg_tools/*");
});
it("version is 0.7.15", () => {
expect(tauriConf.version).toBe("0.7.15");
it("version is 0.7.12", () => {
expect(tauriConf.version).toBe("0.7.12");
});
});
-4
View File
@@ -45,10 +45,6 @@ describe("buildChangeSql", () => {
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\')');
});
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", () => {
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');
-1
View File
@@ -33,7 +33,6 @@ export function buildChangeSql(item: QueueItem): string {
case "insert": {
const data = item.newData ?? {};
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(", ")})`;
}
case "update": {
+7 -7
View File
@@ -4,7 +4,7 @@ import { describe, it, expect } from "vitest";
import agents from "../../../AGENTS.md?raw";
import readme from "../../../README.md?raw";
describe("v0.7.15 docs coverage", () => {
describe("v0.7.12 docs coverage", () => {
it("AGENTS.md marks inline cell editing complete", () => {
expect(agents).toContain("Inline cell editing");
expect(agents).toMatch(/Inline cell editing \| ✅/);
@@ -24,8 +24,8 @@ describe("v0.7.15 docs coverage", () => {
expect(agents).toMatch(/Connection status indicator on cards \| ✅/);
expect(agents).toMatch(/Move-to-folder bulk action \| ✅/);
});
it("README declares v0.7.15", () => {
expect(readme).toContain("0.7.15");
it("README declares v0.7.12", () => {
expect(readme).toContain("0.7.12");
});
it("AGENTS.md marks schema CRUD complete", () => {
expect(agents).toMatch(/Schema CRUD \| ✅/);
@@ -64,9 +64,9 @@ describe("v0.7.15 docs coverage", () => {
expect(agents).toMatch(/Cancel long-running queries \| ✅/);
expect(agents).toMatch(/Settings export\/import \| ✅/);
});
it("README links to v0.7.15 assets in both download tables", () => {
expect(readme).toContain("releases/download/v0.7.15/");
expect(readme).toContain("Gridline_0.7.15_aarch64.dmg");
expect(readme).toContain("Gridline-0.7.15-1.x86_64.rpm");
it("README links to v0.7.12 assets in both download tables", () => {
expect(readme).toContain("releases/download/v0.7.12/");
expect(readme).toContain("Gridline_0.7.12_aarch64.dmg");
expect(readme).toContain("Gridline-0.7.12-1.x86_64.rpm");
});
});
+2 -2
View File
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
import pkg from "../../package.json";
describe("version", () => {
it("declares v0.7.15 across the app shell", () => {
expect(pkg.version).toBe("0.7.15");
it("declares v0.7.12 across the app shell", () => {
expect(pkg.version).toBe("0.7.12");
});
});
-10
View File
@@ -247,16 +247,6 @@ describe("dbViewerStore", () => {
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", () => {
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);
-8
View File
@@ -167,7 +167,6 @@ interface DbViewerState {
}) => void;
cancelChange: (changeId: string) => void;
removeChange: (changeId: string) => void;
updateChange: (changeId: string, patch: Partial<QueueItem>) => void;
clearChanges: () => void;
markChangeCommitted: (changeId: string) => void;
markChangeFailed: (changeId: string, error: string) => void;
@@ -548,13 +547,6 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
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: [] }),
markChangeCommitted: (changeId) =>
+19 -5
View File
@@ -11,8 +11,13 @@
#
# brew install --cask gridline
#
# SIGNING: Gridline is Developer-ID signed + notarized, so macOS trusts it
# and no quarantine-stripping postflight is needed.
# QUARANTINE: Gridline is ad-hoc signed (not Developer-ID signed/notarized),
# so Homebrew casks (which deliberately set com.apple.quarantine on the
# 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
arch arm: "aarch64", intel: "x64"
@@ -21,15 +26,24 @@ cask "gridline" do
sha256 arm: "@ARM_SHA256@",
intel: "@INTEL_SHA256@"
url "https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v#{version}/Gridline_#{version}_#{arch}.dmg"
url "https://github.com/AdrianBonpin/gridline/releases/download/v#{version}/Gridline_#{version}_#{arch}.dmg"
name "Gridline"
desc "Open-source, cross-platform database GUI client for PostgreSQL"
homepage "https://git.ranio.xyz/adrianbonpin/gridline"
homepage "https://github.com/AdrianBonpin/gridline"
depends_on macos: :monterey
depends_on macos: ">= :monterey"
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: [
"~/Library/Application Support/com.adrianbonpin.gridline",
"~/Library/Caches/com.adrianbonpin.gridline",
+3 -96
View File
@@ -45,76 +45,12 @@ get_token() {
if [ -n "$tok" ]; then echo "$tok"; return; fi
fi
fi
# Fall back to the git credential store: tea's OAuth login stores an access
# 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
read -rsp "Gitea personal access token (repo write): " GITEA_TOKEN
echo "$GITEA_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) ----------------------------------------
WORKSPACE="$(cd "$(dirname "$0")/.." && pwd)"
OUT_PG="${WORKSPACE}/desktop/src-tauri/resources/pg_tools"
@@ -124,8 +60,6 @@ echo ">> Building PostgreSQL client tools..."
mkdir -p "$OUT_PG"
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
# 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
cd /tmp/postgresql-${PG_VER}
ac_cv_func_strchrnul=no ./configure --prefix=/tmp/pgbuild --without-readline --without-icu CFLAGS="-O2"
@@ -141,19 +75,10 @@ for b in pg_dump pg_restore psql; do
libpq=$(otool -L "$OUT_PG/$b" | awk '/libpq/ {print $1; exit}')
install_name_tool -change "$libpq" "@loader_path/libpq.5.dylib" "$OUT_PG/$b"
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..."
mkdir -p "$OUT_MY"
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
cd /tmp/mariadb-server
if [ "$(uname -m)" = arm64 ]; then
@@ -187,7 +112,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
install_name_tool -change "$lib" "@loader_path/$base" "$dylib" 2>/dev/null || true
done || true
codesign --force --options runtime --timestamp --sign "$SIGNING_IDENTITY" "$dylib"
codesign --force --sign - "$dylib" 2>/dev/null || true
done
done
@@ -205,24 +130,6 @@ VERSION="${TAG#v}"
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"
# ---- 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 -----------------------------------
API="${GITEA_SERVER}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}"
AUTH="Authorization: token ${GITEA_TOKEN}"
+20 -3
View File
@@ -60,7 +60,7 @@
const keepLabel = downloadBtn.hasAttribute("data-download-label");
const releasesUrl = "https://git.ranio.xyz/adrianbonpin/gridline/releases";
const version = "0.7.15";
const version = "0.7.12";
if (isMobile) {
downloadBtn.href = releasesUrl;
@@ -68,6 +68,9 @@
} else if (isMac) {
downloadBtn.href = `${releasesUrl}/download/v${version}/Gridline_${version}_aarch64.dmg`;
if (!keepLabel) downloadBtn.textContent = "Download for macOS";
if (downloadBtn.id === "download") {
document.getElementById("macos-hint")?.classList.remove("hidden");
}
} else if (isWin) {
downloadBtn.href = `${releasesUrl}/download/v${version}/Gridline_${version}_x64-setup.exe`;
if (!keepLabel) downloadBtn.textContent = "Download for Windows";
@@ -80,6 +83,18 @@
}
});
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
const nav = document.getElementById("nav");
function onScroll() {
@@ -126,22 +141,24 @@
const subtitle = hero.querySelector(".hero-animate > p:nth-of-type(2)");
const buttons = hero.querySelector(".hero-animate > div");
const screenshot = hero.querySelector(".hero-screenshot");
const macosHint = hero.querySelector("#macos-hint");
if (reducedMotion) {
// Reduced motion: reveal the hero immediately (CSS hides it at first
// paint to avoid flicker, but the timeline below is skipped). Section
// reveals below still run through the IntersectionObserver.
anime.set([eyebrow, h1, subtitle, buttons, screenshot], { opacity: 1, translateY: 0 });
anime.set([eyebrow, h1, subtitle, buttons, screenshot, macosHint], { opacity: 1, translateY: 0 });
} else {
const heroTl = anime.timeline({ easing: "easeOutExpo" });
anime.set([eyebrow, h1, subtitle, buttons, screenshot], { opacity: 0, translateY: 20 });
anime.set([eyebrow, h1, subtitle, buttons, screenshot, macosHint], { opacity: 0, translateY: 20 });
anime.set(screenshot, { opacity: 0, scale: 0.96, translateY: 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 (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 (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);
}
}
-25
View File
@@ -5,31 +5,6 @@ export interface 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",
+4 -9
View File
@@ -7,12 +7,12 @@ export const faqItems: FaqItem[] = [
{
question: "Is Gridline really free for commercial use?",
answer:
"Yes. Gridline is Apache 2.0. You can use it for personal, commercial, or internal work without paying or signing up.",
"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.",
},
{
question: "Which databases are supported?",
answer:
"PostgreSQL and SQLite are fully supported today (browsing, querying, editing, backups, and the visual tools). MySQL and Redis connections are in active development.",
"PostgreSQL and SQLite are fully supported today, including browsing, querying, editing, backups, and visual tools. MySQL and Redis connections are in active development.",
},
{
question: "How is this different from TablePlus, Beekeeper Studio, or DB Pro?",
@@ -22,15 +22,10 @@ export const faqItems: FaqItem[] = [
{
question: "Do you store my database credentials?",
answer:
"No. Passwords live in your operating system keychain (macOS Keychain, Linux Secret Service, or Windows Credential Manager), never in Gridline's local SQLite database.",
"Passwords are stored in your operating system keychain — macOS Keychain, Linux Secret Service, or Windows Credential Manager — never in Gridline's local SQLite database.",
},
{
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?",
question: "Can I self-host or contribute?",
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.",
},
+4 -5
View File
@@ -13,7 +13,7 @@ export interface Props {
const {
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",
noIndex = false,
jsonLd = [],
@@ -51,7 +51,7 @@ const isProd = import.meta.env.PROD;
<meta property="og:image" content={ogImageURL} />
<meta property="og:image:width" content="1200" />
<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:locale" content="en_US" />
@@ -59,7 +59,7 @@ const isProd = import.meta.env.PROD;
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={ogImageURL} />
<meta name="twitter:image:alt" content="Gridline, the open-source database GUI" />
<meta name="twitter:image:alt" content="Gridline — the open-source database GUI" />
<meta name="twitter:site" content="@AdrianBonpin" />
<meta name="twitter:creator" content="@AdrianBonpin" />
@@ -149,13 +149,12 @@ const isProd = import.meta.env.PROD;
<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="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>
</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">
<span>© 2026 Gridline contributors · Apache 2.0</span>
<span>Coffee-powered.</span>
<span>Made with care for databases.</span>
</div>
</div>
</footer>
+1 -1
View File
@@ -1,7 +1,7 @@
---
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.";
---
+12 -12
View File
@@ -15,7 +15,7 @@ import queryEditor from "../assets/screenshots/query-editor.png";
import erDiagram from "../assets/screenshots/er-diagram.png";
import home from "../assets/screenshots/home.png";
import dataGrid from "../assets/screenshots/data-grid.png";
const version = "0.7.15";
const version = "0.7.12";
const site = "https://getgridline.app";
const jsonLd = [
buildSoftwareApplicationJsonLd({
@@ -58,7 +58,7 @@ const jsonLd = [
]),
];
---
<Layout title="Gridline, Free Open-Source Database GUI" jsonLd={jsonLd}>
<Layout title="Gridline — Free Open-Source Database GUI" jsonLd={jsonLd}>
<script src="/landing.js" defer></script>
<!-- Hero -->
@@ -70,7 +70,7 @@ const jsonLd = [
the open-source database <span class="text-[var(--accent)]">gui</span>
</h1>
<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>
<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>
@@ -80,6 +80,14 @@ const jsonLd = [
View on Gitea
</a>
</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 class="hero-screenshot mt-14 md:mt-20 mx-auto max-w-6xl">
@@ -176,21 +184,13 @@ const jsonLd = [
<section id="oss" class="section-animate py-20 md:py-28 text-center">
<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>
<h2 class="font-serif text-3xl md:text-5xl mb-5">Free. Open source. Apache 2.0.</h2>
<h2 class="font-serif text-3xl md:text-5xl mb-5">Built in public.<br />No VC roadmap, no rug pulls.</h2>
<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.
</p>
<div class="flex flex-wrap items-center justify-center gap-3">
<a href="https://git.ranio.xyz/adrianbonpin/gridline" target="_blank" rel="noopener" class="ghost-btn">
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>
</section>