Compare commits
14
Commits
9611567daa
...
v0.7.15
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdf2bb543b | ||
|
|
67f3532938 | ||
|
|
1d8dfce99a | ||
|
|
8bcdc0888f | ||
|
|
fd451d219d | ||
|
|
a86f5f7199 | ||
|
|
2fccb7b405 | ||
|
|
0efabbdc2e | ||
|
|
294e72e6ff | ||
|
|
15f4dc9bac | ||
|
|
867df98958 | ||
|
|
e66d7ac102 | ||
|
|
84a6f8d41c | ||
|
|
08b0f79f07 |
@@ -7,16 +7,11 @@ name: Release
|
|||||||
# git checkout prod && git pull
|
# git checkout prod && git pull
|
||||||
# git tag v0.5.0 && git push origin v0.5.0
|
# git tag v0.5.0 && git push origin v0.5.0
|
||||||
#
|
#
|
||||||
# SIGNING STATUS: macOS bundles are AD-HOC signed via tauri.conf.json
|
# SIGNING STATUS: macOS is Developer-ID signed + notarized via
|
||||||
# (bundle.macOS.signingIdentity: "-", hardenedRuntime: false). That replaces
|
# scripts/release-mac.sh (built manually on Adrian's machine). NOTE: this
|
||||||
# the linker-only signature that made macOS report the app as "damaged" and
|
# GitHub workflow is NOT the active release path — Gitea Actions uses
|
||||||
# refuse Finder launches — users now get the standard "Open Anyway" prompt
|
# .gitea/workflows/release.yml (Linux/Windows only). Linux/Windows installers
|
||||||
# (or run `sudo xattr -dr com.apple.quarantine` for the damaged-error case, see
|
# remain unsigned.
|
||||||
# README). Still NOT Developer-ID signed / notarized (no certs yet).
|
|
||||||
# tauri-action automatically signs + notarizes when the signing secrets are
|
|
||||||
# present, so the moment we add APPLE_CERTIFICATE / APPLE_API_KEY /
|
|
||||||
# WINDOWS_CERTIFICATE (or Azure Trusted Signing) to repo secrets, future
|
|
||||||
# builds are properly signed — no changes to this file required.
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ Cut a release by tagging the **`prod`** branch once the PR is merged — `git ta
|
|||||||
- `desktop/src/lib/version.test.ts` and `desktop/src/lib/docs-coverage.test.ts` if they assert the version
|
- `desktop/src/lib/version.test.ts` and `desktop/src/lib/docs-coverage.test.ts` if they assert the version
|
||||||
- **README download links are static (versioned)** — both download tables (top **Download** section + **Which file should I download?**) link directly to the release-tag assets (`releases/download/v0.7.9/<file>`). tauri-action uses default versioned asset names (`Gridline_<ver>_aarch64.dmg`, `Gridline-<ver>-1.x86_64.rpm`, etc.) — update BOTH tables to the new names on every release (see the MAINTENANCE comment in README.md).
|
- **README download links are static (versioned)** — both download tables (top **Download** section + **Which file should I download?**) link directly to the release-tag assets (`releases/download/v0.7.9/<file>`). tauri-action uses default versioned asset names (`Gridline_<ver>_aarch64.dmg`, `Gridline-<ver>-1.x86_64.rpm`, etc.) — update BOTH tables to the new names on every release (see the MAINTENANCE comment in README.md).
|
||||||
- **Bundled pg tools:** `tauri.conf.json` `bundle.resources` lists `resources/pg_tools/*`; the `release.yml` matrix builds/downloads + checksum-verifies the static binaries before the Tauri build step.
|
- **Bundled pg tools:** `tauri.conf.json` `bundle.resources` lists `resources/pg_tools/*`; the `release.yml` matrix builds/downloads + checksum-verifies the static binaries before the Tauri build step.
|
||||||
- **Release notes must include the macOS first-launch instructions** — until a Developer ID signing certificate is secured, releases are ad-hoc signed only (not notarized), so every macOS user hits a Gatekeeper prompt. The draft release body must include: right-click → **Open** → **Open** (or System Settings → Privacy & Security → **Open Anyway**) for "developer cannot be verified", and `sudo xattr -dr com.apple.quarantine /Applications/Gridline.app` (with the re-run-after-every-upgrade note) for "damaged and can't be opened".
|
- **macOS is Developer-ID signed + notarized** via `scripts/release-mac.sh` (built manually on Adrian's machine; not in CI). The script auto-detects the Developer ID identity from the login keychain, reads notarization credentials from `~/.config/gridline/notarize.env` (App Store Connect API key), and verifies codesign + stapled notarization before uploading. If the cert or credentials are missing it fails with setup instructions (see the README's "macOS signing & notarization" section).
|
||||||
|
|
||||||
### Adding a Tauri Command
|
### Adding a Tauri Command
|
||||||
|
|
||||||
@@ -244,7 +244,7 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
|||||||
| DB Viewer: SQLite browse + query | ✅ | Full support via rusqlite |
|
| DB Viewer: SQLite browse + query | ✅ | Full support via rusqlite |
|
||||||
| DB Viewer: MySQL browse + query + edit | ✅ | Full viewer: connect (SSL + SSH tunnel), databases/tables/columns/FKs, query + pagination, inline cell editing + changes queue, DDL copy (`SHOW CREATE TABLE`), CSV/JSON import — added in v0.7.0. PK-only editing (no ctid equivalent); VARBINARY `information_schema` columns decoded correctly |
|
| DB Viewer: MySQL browse + query + edit | ✅ | Full viewer: connect (SSL + SSH tunnel), databases/tables/columns/FKs, query + pagination, inline cell editing + changes queue, DDL copy (`SHOW CREATE TABLE`), CSV/JSON import — added in v0.7.0. PK-only editing (no ctid equivalent); VARBINARY `information_schema` columns decoded correctly |
|
||||||
| DB Viewer: Redis browse | ❌ | Connection + test only; browsing gated off with a clean "not supported" state (v0.7.0) |
|
| DB Viewer: Redis browse | ❌ | Connection + test only; browsing gated off with a clean "not supported" state (v0.7.0) |
|
||||||
| Password storage in OS keychain | ✅ | macOS Keychain, Linux Secret Service, Windows Credential Manager |
|
| Password storage in OS keychain | ✅ | macOS Keychain, Linux Secret Service, Windows Credential Manager; macOS items get an explicit `SecAccess` ACL pinning them to the app's code-signature requirement on every save (`keychain_acl.rs`) — items survive re-signs/upgrades without keychain permission prompts |
|
||||||
| Enable keychain toggle | ✅ | Default ON (opt-out); OFF = don't persist the DB password (session-only, re-prompt on connect) + purge existing keychain entry; SSH secrets stay keychain-only (v0.7.6) |
|
| Enable keychain toggle | ✅ | Default ON (opt-out); OFF = don't persist the DB password (session-only, re-prompt on connect) + purge existing keychain entry; SSH secrets stay keychain-only (v0.7.6) |
|
||||||
| SSH tunnel config UI | ✅ | Host, port, user, auth method, key path, passphrase fields |
|
| SSH tunnel config UI | ✅ | Host, port, user, auth method, key path, passphrase fields |
|
||||||
| SSH tunnel runtime | ✅ | Real ssh2 tunnel (password + key auth), binds 127.0.0.1 only, secrets in OS keychain (`ssh_password:<id>` / `ssh_passphrase:<id>`), closed on pool eviction / app exit; TLS downgraded to `require` through the tunnel |
|
| SSH tunnel runtime | ✅ | Real ssh2 tunnel (password + key auth), binds 127.0.0.1 only, secrets in OS keychain (`ssh_password:<id>` / `ssh_passphrase:<id>`), closed on pool eviction / app exit; TLS downgraded to `require` through the tunnel |
|
||||||
@@ -290,7 +290,7 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
|||||||
| Smart default sort | ✅ | 12-tier priority: updated_at → created_at → *_at → *_id → seq/rank/version |
|
| Smart default sort | ✅ | 12-tier priority: updated_at → created_at → *_at → *_id → seq/rank/version |
|
||||||
| Data grid pagination | ✅ | Page nav, page size selector persisted in settings |
|
| Data grid pagination | ✅ | Page nav, page size selector persisted in settings |
|
||||||
| Column filtering (server-side) | ✅ | eq, neq, contains, starts, ends, gt, lt, null, notnull pushed to SQL WHERE |
|
| Column filtering (server-side) | ✅ | eq, neq, contains, starts, ends, gt, lt, null, notnull pushed to SQL WHERE |
|
||||||
| Visual filter builder | ✅ | Drag-and-drop column palette (@dnd-kit) with type-aware operators (textish → contains, else eq), AND semantics, persists in tab `filterRules` |
|
| Visual filter builder | ✅ | Column Filters popover with a "+ Add filter" row editor (column / operator / value selects, per-rule remove, AND semantics, persists in tab `filterRules`). Free-text values are debounced (400ms) so a query only fires after the user stops typing; column/operator changes apply immediately. The filter button is a plain toggle (open when closed, close when open) |
|
||||||
| Column sorting (server-side) | ✅ | Multi-column asc/desc pushed to SQL ORDER BY |
|
| Column sorting (server-side) | ✅ | Multi-column asc/desc pushed to SQL ORDER BY |
|
||||||
| Column show/hide | ✅ | Toggle visibility per column |
|
| Column show/hide | ✅ | Toggle visibility per column |
|
||||||
| Column resize (drag handle) | ✅ | Double-click to auto-fit |
|
| Column resize (drag handle) | ✅ | Double-click to auto-fit |
|
||||||
@@ -298,13 +298,13 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
|||||||
| Export toolbar (JSON, CSV, SQL, Markdown, Excel (.xlsx)) | ✅ | Client-side Blob download of visible rows; Excel (.xlsx) via client-side workbook generation (v0.7.8) |
|
| Export toolbar (JSON, CSV, SQL, Markdown, Excel (.xlsx)) | ✅ | Client-side Blob download of visible rows; Excel (.xlsx) via client-side workbook generation (v0.7.8) |
|
||||||
| Excel (.xlsx) export | ✅ | Client-side .xlsx export of visible rows alongside JSON/CSV/SQL/Markdown (v0.7.8) |
|
| Excel (.xlsx) export | ✅ | Client-side .xlsx export of visible rows alongside JSON/CSV/SQL/Markdown (v0.7.8) |
|
||||||
| Auto-refresh timer | ✅ | Configurable interval in settings |
|
| Auto-refresh timer | ✅ | Configurable interval in settings |
|
||||||
| Changes queue (INSERT, UPDATE, DELETE, bulk_insert, empty_table, drop_table) | ✅ | Stage → **Commit All**. Tab bar **Changes** button (amber border + count badge when pending) toggles a **popover** anchored to it: header with **Visual/SQL** toggle (cards showing op badge + table + description + per-change **Revert**, or a generated-SQL preview via `buildChangeSql`), footer **Clear All** + **Commit All (N)** with **⌘S/Ctrl+S** shortcut. Committed cards show a green ✓ (failed ✗); committing `drop_table` auto-closes open tabs of that table |
|
| Changes queue (INSERT, UPDATE, DELETE, bulk_insert, empty_table, drop_table) | ✅ | Stage → **Commit All**. Tab bar **Changes** button (amber border + count badge when pending) toggles a **popover** anchored to it: header with **Visual/SQL** toggle (cards showing op badge + table + description + per-change **Revert**, or a generated-SQL preview via `buildChangeSql`), footer **Clear All** + **Commit All (N)** with **⌘S/Ctrl+S** shortcut. Committed cards show a green ✓ (failed ✗); committing `drop_table` auto-closes open tabs of that table. **Insert Row** adds an editable pending row at the top of the grid (accent outline); committing inserts only the filled columns so serial/identity/generated defaults apply |
|
||||||
| Auto schema-tree refresh | ✅ | Tree auto-refreshes after a successful schema-modifying query run (`CREATE`/`DROP`/`ALTER`/`TRUNCATE` via `isSchemaModifyingQuery`) and after committing schema-modifying queue changes — `drop_table`, schema-modifying `ddl` (e.g. `CREATE TABLE`), and `rebuild_table` — no manual refresh needed |
|
| Auto schema-tree refresh | ✅ | Tree auto-refreshes after a successful schema-modifying query run (`CREATE`/`DROP`/`ALTER`/`TRUNCATE` via `isSchemaModifyingQuery`) and after committing schema-modifying queue changes — `drop_table`, schema-modifying `ddl` (e.g. `CREATE TABLE`), and `rebuild_table` — no manual refresh needed |
|
||||||
| Data import (CSV/JSON) | ✅ | Table overflow menu → ImportDialog: file pick, parse, preview (first 100 rows), header→column mapping, caps 100k rows / 100 MB; stages a bulk_insert change through the queue → Commit All |
|
| Data import (CSV/JSON) | ✅ | Table overflow menu → ImportDialog: file pick, parse, preview (first 100 rows), header→column mapping, caps 100k rows / 100 MB; stages a bulk_insert change through the queue → Commit All |
|
||||||
| Table menu actions | ✅ | Copy table schema (DDL via pg_dump / sqlite_master), Empty Table (DELETE) / Delete Table (DROP) through the queue with confirm, export stubs wired (JSON/CSV/SQL/Markdown) |
|
| Table menu actions | ✅ | Copy table schema (DDL via pg_dump / sqlite_master), Empty Table (DELETE) / Delete Table (DROP) through the queue with confirm, export stubs wired (JSON/CSV/SQL/Markdown) |
|
||||||
| Edit connection modal (from DB viewer) | ✅ | AnimatedModal with keychain password fetch on test |
|
| Edit connection modal (from DB viewer) | ✅ | AnimatedModal with keychain password fetch on test |
|
||||||
| Connection drop banner | ✅ | Auto-detects broken connections with reconnect prompt |
|
| Connection drop banner | ✅ | Auto-detects broken connections with reconnect prompt |
|
||||||
| Inline cell editing | ✅ | Double-click/Enter edits a cell; commit stages an `update` change in the queue → Commit All. No-PK tables use ctid/rowid locator; PK/generated/identity columns and views/matviews are read-only. Stale-write protection via affected-row-count check. **Optimistic UI**: the changes queue is the single source of truth — `deriveStagedValues` feeds staged values + a pending amber dot back into the grid (dot clears on commit, values survive until refetch); re-editing the same cell replaces the queue entry (original `oldData` kept); Clear All removes dots instantly; queue cards show an old → new value diff. **Smart editors**: PG enum columns render a `<select>` of enum labels; FK columns render a searchable dropdown of referenced rows (one row per option showing the first 4 referenced columns, FK-popover styling, 360px fixed, portal to body); `text`/`json`/`jsonb` use a single-line scrolling textarea. |
|
| Inline cell editing | ✅ | Double-click/Enter edits a cell; commit stages an `update` change in the queue → Commit All. No-PK tables use ctid/rowid locator; PK/generated/identity columns and views/matviews are read-only. Stale-write protection via affected-row-count check. **Optimistic UI**: the changes queue is the single source of truth — `deriveStagedValues` feeds staged values + a pending amber dot back into the grid (dot clears on commit, values survive until refetch); re-editing the same cell replaces the queue entry (original `oldData` kept); Clear All removes dots instantly; queue cards show an old → new value diff. **Smart editors**: PG enum columns render a `<select>` of enum labels; FK columns render a searchable dropdown of referenced rows (one row per option showing the first 4 referenced columns, FK-popover styling, 360px fixed, portal to body); `date`/`time`/`timestamp` columns use native date/time/datetime pickers, `boolean` a true/false select, and numeric types a number input; `text`/`json`/`jsonb` use a single-line scrolling textarea. |
|
||||||
| Virtualized data grid | ✅ | Row-level virtualization via @tanstack/react-virtual `useVirtualizer`; handles 100k+ rows |
|
| Virtualized data grid | ✅ | Row-level virtualization via @tanstack/react-virtual `useVirtualizer`; handles 100k+ rows |
|
||||||
| Row detail / expandable row view | ✅ | RowDetailDrawer: right-drawer per row, opened via the cell context menu **View Row** |
|
| Row detail / expandable row view | ✅ | RowDetailDrawer: right-drawer per row, opened via the cell context menu **View Row** |
|
||||||
| Keyboard cell navigation (arrow keys, Tab) | ✅ | Arrow keys + Tab/Shift+Tab wrap (`keyboardNav`); Enter opens the editor; **Esc cancels editing even after the editor lost focus** (document-level listener) and closes the context menu |
|
| Keyboard cell navigation (arrow keys, Tab) | ✅ | Arrow keys + Tab/Shift+Tab wrap (`keyboardNav`); Enter opens the editor; **Esc cancels editing even after the editor lost focus** (document-level listener) and closes the context menu |
|
||||||
|
|||||||
@@ -30,31 +30,34 @@
|
|||||||
|
|
||||||
## Download
|
## Download
|
||||||
|
|
||||||
Grab the installer for your OS from the [latest release](https://git.ranio.xyz/adrianbonpin/gridline/releases/latest) — the links below point at the current release (**v0.7.12**):
|
Grab the installer for your OS from the [latest release](https://git.ranio.xyz/adrianbonpin/gridline/releases/latest) — the links below point at the current release (**v0.7.15**):
|
||||||
|
|
||||||
| OS | Architecture | Download |
|
| OS | Architecture | Download |
|
||||||
| :--------------------------- | :--------------------------- | :----------------------------------------------------------------------------------------------------------------------------------- |
|
| :--------------------------- | :--------------------------- | :----------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| **macOS** | Apple Silicon (M1/M2/M3/M4…) | [Gridline_0.7.12_aarch64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_aarch64.dmg) |
|
| **macOS** | Apple Silicon (M1/M2/M3/M4…) | [Gridline_0.7.15_aarch64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.15/Gridline_0.7.15_aarch64.dmg) |
|
||||||
| **macOS** | Intel | [Gridline_0.7.12_x64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_x64.dmg) |
|
| **macOS** | Intel | [Gridline_0.7.15_x64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.15/Gridline_0.7.15_x64.dmg) |
|
||||||
| **Windows** | x64 | [Gridline_0.7.12_x64-setup.exe](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_x64-setup.exe) |
|
| **Windows** | x64 | [Gridline_0.7.15_x64-setup.exe](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.15/Gridline_0.7.15_x64-setup.exe) |
|
||||||
| **Debian / Ubuntu** | amd64 | [Gridline_0.7.12_amd64.deb](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_amd64.deb) |
|
| **Debian / Ubuntu** | amd64 | [Gridline_0.7.15_amd64.deb](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.15/Gridline_0.7.15_amd64.deb) |
|
||||||
| **Fedora / RHEL / openSUSE** | x86_64 | [Gridline-0.7.12-1.x86_64.rpm](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline-0.7.12-1.x86_64.rpm) |
|
| **Fedora / RHEL / openSUSE** | x86_64 | [Gridline-0.7.15-1.x86_64.rpm](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.15/Gridline-0.7.15-1.x86_64.rpm) |
|
||||||
| **Other Linux** | amd64 | [Gridline_0.7.12_amd64.AppImage](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_amd64.AppImage) |
|
| **Other Linux** | amd64 | [Gridline_0.7.15_amd64.AppImage](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.15/Gridline_0.7.15_amd64.AppImage) |
|
||||||
|
|
||||||
**macOS via Homebrew** (recommended for Mac users):
|
**macOS via Homebrew** (recommended for Mac users):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
brew install --cask AdrianBonpin/gridline/gridline
|
# one-time setup (the tap is hosted on Gitea, not GitHub):
|
||||||
# or, after a one-time `brew tap AdrianBonpin/gridline`:
|
brew tap AdrianBonpin/gridline https://git.ranio.xyz/adrianbonpin/homebrew-gridline.git
|
||||||
|
brew trust adrianbonpin/gridline
|
||||||
|
|
||||||
|
# install / upgrade:
|
||||||
brew install --cask gridline
|
brew install --cask gridline
|
||||||
```
|
```
|
||||||
|
|
||||||
The cask strips the macOS quarantine flag automatically during install, so **no manual `xattr` step is needed** on the Homebrew path — the app just launches. (Direct-DMG downloads still need the [first-launch instructions](#installers-are-unsigned-for-now) below.)
|
The cask installs the app directly. The DMGs are Developer-ID signed and notarized, so macOS trusts them on first launch — no `xattr` or "Open Anyway" step needed.
|
||||||
|
|
||||||
> **macOS first launch:** macOS may say *"Gridline is damaged and can't be opened"* or *"the developer cannot be verified"* — this is expected; the app isn't Developer-ID signed/notarized yet. See the [macOS first-launch instructions](#installers-are-unsigned-for-now) (right-click → Open, or the one-time `xattr` fix). Not sure if your Mac is Intel or Apple Silicon? See [Which file should I download?](#which-file-should-i-download) below.
|
> **macOS first launch:** Gridline is Developer-ID signed and notarized, so macOS opens it without a Gatekeeper prompt. Not sure if your Mac is Intel or Apple Silicon? See [Which file should I download?](#which-file-should-i-download) below.
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
MAINTENANCE: These links are STATIC (versioned) — they point at the v0.7.12
|
MAINTENANCE: These links are STATIC (versioned) — they point at the v0.7.15
|
||||||
release assets, not at a moving "latest" target. On every new release,
|
release assets, not at a moving "latest" target. On every new release,
|
||||||
update BOTH tables here (Download + Which file should I download?) to the
|
update BOTH tables here (Download + Which file should I download?) to the
|
||||||
new version's asset names, which are tauri-action's default naming:
|
new version's asset names, which are tauri-action's default naming:
|
||||||
@@ -99,6 +102,12 @@ Gridline is built for developers and small teams who manage multiple database en
|
|||||||
<details>
|
<details>
|
||||||
<summary>View the full changelog</summary>
|
<summary>View the full changelog</summary>
|
||||||
|
|
||||||
|
- **v0.7.15** — **Support channel on the landing page**: optional coffee via [Ko-fi](https://ko-fi.com/adrianbonpin), never gates a feature. Landing copy refreshed (em dashes dropped, OSS section simplified, funding FAQ); version bump.
|
||||||
|
|
||||||
|
- **v0.7.14** — **No more repeated macOS keychain prompts**: saved connection passwords and SSH secrets are now ACL-pinned to the app's code signature on every save (`keychain_acl`), so app upgrades never re-trigger the macOS keychain access dialog (users upgrading from pre-signing builds see one final prompt — choose **Always Allow**). **Install via Homebrew**: `brew tap AdrianBonpin/gridline https://git.ranio.xyz/adrianbonpin/homebrew-gridline.git` then `brew install --cask gridline`; version bump.
|
||||||
|
|
||||||
|
- **v0.7.13** — **macOS is now Developer-ID signed and notarized**: the app is properly code-signed with a Developer ID Application certificate and notarized by Apple, so macOS opens it without the Gatekeeper "Open Anyway" prompt or the `xattr` quarantine workaround. The release script (`scripts/release-mac.sh`) auto-detects the signing identity from the keychain, notarizes + staples both architectures, and verifies the result before uploading; version bump.
|
||||||
|
|
||||||
- **v0.7.12** — **Reliable cell editing for non-text columns**: editing a PostgreSQL cell whose type isn't `text` (integers, booleans, UUIDs, `json`/`jsonb`, timestamps, enums, numerics, …) previously failed with a Rust serialization error; edited values are now sent to PostgreSQL in text wire format so the server parses them into the column's own type. **Recent connections fix**: the Recent strip no longer renders empty on first launch when the connections list hadn't loaded yet. **Full CI release pipeline for Windows**: self-hosted Windows runner builds the `.exe`/`.msi` installers (bundled `pg_dump`/`pg_restore`/`psql` + MariaDB clients) and uploads them to the release alongside Linux; version bump.
|
- **v0.7.12** — **Reliable cell editing for non-text columns**: editing a PostgreSQL cell whose type isn't `text` (integers, booleans, UUIDs, `json`/`jsonb`, timestamps, enums, numerics, …) previously failed with a Rust serialization error; edited values are now sent to PostgreSQL in text wire format so the server parses them into the column's own type. **Recent connections fix**: the Recent strip no longer renders empty on first launch when the connections list hadn't loaded yet. **Full CI release pipeline for Windows**: self-hosted Windows runner builds the `.exe`/`.msi` installers (bundled `pg_dump`/`pg_restore`/`psql` + MariaDB clients) and uploads them to the release alongside Linux; version bump.
|
||||||
|
|
||||||
- **v0.7.10** — **Copy connection URL**: from a connection card's ⋮ menu, copy the connection string for use in env vars / other tools — **Copy connection URL** (includes the password, fetched from the OS keychain on demand) and **Copy connection URL (no password)** (safe to share). Builds `postgresql://` / `mysql://` / `sqlite://` / `redis://` strings with percent-encoded credentials and the PG `sslmode` appended; version bump.
|
- **v0.7.10** — **Copy connection URL**: from a connection card's ⋮ menu, copy the connection string for use in env vars / other tools — **Copy connection URL** (includes the password, fetched from the OS keychain on demand) and **Copy connection URL (no password)** (safe to share). Builds `postgresql://` / `mysql://` / `sqlite://` / `redis://` strings with percent-encoded credentials and the PG `sslmode` appended; version bump.
|
||||||
@@ -307,37 +316,40 @@ Pre-built installers for macOS, Windows, and Linux are published on the [Release
|
|||||||
|
|
||||||
> ⚠️ Gridline is under active development. Expect rough edges and please [open issues](https://git.ranio.xyz/adrianbonpin/gridline/issues/new) when you hit them.
|
> ⚠️ Gridline is under active development. Expect rough edges and please [open issues](https://git.ranio.xyz/adrianbonpin/gridline/issues/new) when you hit them.
|
||||||
|
|
||||||
#### Installers are unsigned (for now)
|
#### macOS signing & notarization
|
||||||
|
|
||||||
Gridline is currently distributed **unsigned** — it doesn't pay for code-signing certificates yet (macOS builds are *ad-hoc signed*, so they pass Apple Silicon's launch checks but aren't Developer-ID signed or notarized). Your OS will warn you the first time you open it. This is expected — the app is safe, it just hasn't paid the signing fee:
|
Gridline's macOS builds are **Developer-ID signed and notarized**, so macOS opens them without a Gatekeeper prompt. This is configured on the machine that runs `scripts/release-mac.sh` (macOS is built manually, not in CI).
|
||||||
|
|
||||||
> **Homebrew users:** the [Homebrew cask](#download) strips the quarantine flag automatically during install, so you skip all of the steps below — the app just launches.
|
**One-time setup** (only the repo owner needs to do this):
|
||||||
|
|
||||||
- **macOS:** if you see *"Gridline can't be opened because the developer cannot be verified"* (or the app simply won't open from Finder), right-click the app → **Open** → **Open** (or System Settings → Privacy & Security → **Open Anyway**). Do this once per version.
|
1. **Create a Developer ID Application certificate** — [developer.apple.com](https://developer.apple.com) → Certificates, Identifiers & Profiles → **+** → **Developer ID Application**. Generate a Certificate Signing Request from Keychain Access (Certificate Assistant → *Request a Certificate From a Certificate Authority*), upload it, download the `.cer`, and double-click to install into your login keychain. Verify with `security find-identity -v -p codesigning`.
|
||||||
- **macOS — "Gridline is damaged and can't be opened":** remove the quarantine flag macOS attaches to downloaded apps, then launch normally:
|
2. **Create an App Store Connect API key** for notarization — [appstoreconnect.apple.com](https://appstoreconnect.apple.com) → Users and Access → **Integrations** → **App Store Connect API** → **+** (name it, *Developer* access). Download the `.p8` (shown once) and note the **Key ID** and **Issuer ID**.
|
||||||
|
3. **Save the notarization credentials** to `~/.config/gridline/notarize.env` (never committed):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo xattr -dr com.apple.quarantine /Applications/Gridline.app
|
APPLE_API_KEY=<Key ID>
|
||||||
```
|
APPLE_API_ISSUER=<Issuer ID>
|
||||||
|
APPLE_API_KEY_PATH=/absolute/path/to/AuthKey_<KeyID>.p8
|
||||||
|
```
|
||||||
|
|
||||||
(`sudo` is required — some files inside the bundle are read-only, so removing the flag needs admin rights.) Re-run after **every upgrade** — each freshly-downloaded copy gets re-quarantined.
|
(Alternatively, drop the `.p8` at `~/.appstoreconnect/private_keys/AuthKey_<KeyID>.p8` and omit `APPLE_API_KEY_PATH`.)
|
||||||
- **Windows:** on the SmartScreen prompt, click **More info** → **Run anyway**.
|
|
||||||
- **Linux:** no warning — install and run normally.
|
|
||||||
|
|
||||||
Code signing **will be added in the future** (Apple Developer Program + a Windows signing cert, e.g. Azure Trusted Signing) — the CI workflow is already wired to pick up the signing secrets automatically the moment they exist, no workflow changes needed.
|
`scripts/release-mac.sh` auto-detects the Developer ID identity from your keychain, signs both architectures, notarizes + staples them, and verifies the result before uploading. If the certificate or credentials are missing it fails with instructions (set `GRIDLINE_SKIP_NOTARIZE=1` to build signed-but-unnotarized for testing only).
|
||||||
|
|
||||||
|
**Windows** still shows a SmartScreen prompt (no Windows signing cert yet); **Linux** installs without a warning.
|
||||||
|
|
||||||
#### Which file should I download?
|
#### Which file should I download?
|
||||||
|
|
||||||
Each release contains **one file per platform** — you only need the one that matches your computer. The links below point at the current release (**v0.7.12**):
|
Each release contains **one file per platform** — you only need the one that matches your computer. The links below point at the current release (**v0.7.15**):
|
||||||
|
|
||||||
| Your system | Download this | Notes |
|
| Your system | Download this | Notes |
|
||||||
| :------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------- |
|
| :------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------- |
|
||||||
| macOS **Apple Silicon** (M1/M2/M3/M4…) | [Gridline_0.7.12_aarch64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_aarch64.dmg) | `aarch64` = Apple's own chip |
|
| macOS **Apple Silicon** (M1/M2/M3/M4…) | [Gridline_0.7.15_aarch64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.15/Gridline_0.7.15_aarch64.dmg) | `aarch64` = Apple's own chip |
|
||||||
| macOS **Intel** | [Gridline_0.7.12_x64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_x64.dmg) | `x64` = Intel/AMD |
|
| macOS **Intel** | [Gridline_0.7.15_x64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.15/Gridline_0.7.15_x64.dmg) | `x64` = Intel/AMD |
|
||||||
| **Windows** (most PCs) | [Gridline_0.7.12_x64-setup.exe](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_x64-setup.exe) | The `.msi` is an alternate installer (for enterprises/IT admins) |
|
| **Windows** (most PCs) | [Gridline_0.7.15_x64-setup.exe](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.15/Gridline_0.7.15_x64-setup.exe) | The `.msi` is an alternate installer (for enterprises/IT admins) |
|
||||||
| **Debian / Ubuntu** | [Gridline_0.7.12_amd64.deb](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_amd64.deb) | Install: `sudo apt install ./Gridline_0.7.12_amd64.deb` |
|
| **Debian / Ubuntu** | [Gridline_0.7.15_amd64.deb](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.15/Gridline_0.7.15_amd64.deb) | Install: `sudo apt install ./Gridline_0.7.15_amd64.deb` |
|
||||||
| **Fedora / RHEL / openSUSE** | [Gridline-0.7.12-1.x86_64.rpm](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline-0.7.12-1.x86_64.rpm) | Install: `sudo dnf install Gridline-0.7.12-1.x86_64.rpm` |
|
| **Fedora / RHEL / openSUSE** | [Gridline-0.7.15-1.x86_64.rpm](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.15/Gridline-0.7.15-1.x86_64.rpm) | Install: `sudo dnf install Gridline-0.7.15-1.x86_64.rpm` |
|
||||||
| **Any other Linux** | [Gridline_0.7.12_amd64.AppImage](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_amd64.AppImage) | Works on every distro: `chmod +x` the file, then double-click it |
|
| **Any other Linux** | [Gridline_0.7.15_amd64.AppImage](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.15/Gridline_0.7.15_amd64.AppImage) | Works on every distro: `chmod +x` the file, then double-click it |
|
||||||
|
|
||||||
**Not sure if your Mac is Intel or Apple Silicon?** Click the **Apple menu** → **About This Mac**. If it shows "Apple M1/M2/M3/M4…" download the `aarch64` file; if it shows an Intel chip, download `x64`. Downloading the wrong one won't run.
|
**Not sure if your Mac is Intel or Apple Silicon?** Click the **Apple menu** → **About This Mac**. If it shows "Apple M1/M2/M3/M4…" download the `aarch64` file; if it shows an Intel chip, download `x64`. Downloading the wrong one won't run.
|
||||||
|
|
||||||
@@ -347,8 +359,8 @@ Cutting a release is one command — CI builds everything. **Releases are cut fr
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
git checkout prod && git pull
|
git checkout prod && git pull
|
||||||
git tag v0.7.12
|
git tag v0.7.15
|
||||||
git push origin v0.7.12
|
git push origin v0.7.15
|
||||||
```
|
```
|
||||||
|
|
||||||
Gitea Actions (`.gitea/workflows/release.yml`) builds the **Linux and Windows** installers on the self-hosted runners, then opens a **draft release** on the [Releases](https://git.ranio.xyz/adrianbonpin/gridline/releases) page. **macOS is built manually** on the dev Mac and its DMGs uploaded to the same release via `scripts/release-mac.sh`. Review the draft and hit **Publish release**.
|
Gitea Actions (`.gitea/workflows/release.yml`) builds the **Linux and Windows** installers on the self-hosted runners, then opens a **draft release** on the [Releases](https://git.ranio.xyz/adrianbonpin/gridline/releases) page. **macOS is built manually** on the dev Mac and its DMGs uploaded to the same release via `scripts/release-mac.sh`. Review the draft and hit **Publish release**.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "gridline-desktop",
|
"name": "gridline-desktop",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.7.12",
|
"version": "0.7.15",
|
||||||
"description": "An open-source, high-performance database GUI client for PostgreSQL and beyond",
|
"description": "An open-source, high-performance database GUI client for PostgreSQL and beyond",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Generated
+4
-1
@@ -1783,10 +1783,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gridline"
|
name = "gridline"
|
||||||
version = "0.7.12"
|
version = "0.7.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"core-foundation 0.10.1",
|
||||||
"deadpool-postgres",
|
"deadpool-postgres",
|
||||||
"indexmap 2.14.0",
|
"indexmap 2.14.0",
|
||||||
"redis",
|
"redis",
|
||||||
@@ -1795,6 +1796,8 @@ dependencies = [
|
|||||||
"rustls-native-certs",
|
"rustls-native-certs",
|
||||||
"rustls-pemfile 2.2.0",
|
"rustls-pemfile 2.2.0",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
|
"security-framework 3.7.0",
|
||||||
|
"security-framework-sys",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "gridline"
|
name = "gridline"
|
||||||
version = "0.7.12"
|
version = "0.7.15"
|
||||||
description = "An open-source, high-performance database GUI client for PostgreSQL and beyond"
|
description = "An open-source, high-performance database GUI client for PostgreSQL and beyond"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
@@ -49,3 +49,12 @@ rustls-pemfile = "2"
|
|||||||
rustls-pki-types = "1"
|
rustls-pki-types = "1"
|
||||||
rustls-native-certs = "0.7"
|
rustls-native-certs = "0.7"
|
||||||
|
|
||||||
|
# Keychain ACL normalization (explicit SecAccess on stored secrets so items
|
||||||
|
# survive app re-signs without permission prompts) — macOS only.
|
||||||
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
|
security-framework = "3.7"
|
||||||
|
security-framework-sys = "2.17"
|
||||||
|
# core-foundation: CFString/CFArray/TCFType helpers for the SecAccess FFI
|
||||||
|
# (already a transitive dep of security-framework; pinned to the same 0.10 line)
|
||||||
|
core-foundation = "0.10"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!-- Required for the WKWebView (JIT-compiled JavaScript). Without this the
|
||||||
|
app crashes under the hardened runtime. -->
|
||||||
|
<key>com.apple.security.cs.allow-jit</key>
|
||||||
|
<true/>
|
||||||
|
<!-- The WebView allocates executable memory for JIT; allow it. -->
|
||||||
|
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||||
|
<true/>
|
||||||
|
<!-- Tauri's dev server / debug builds rely on DYLD_* environment variables. -->
|
||||||
|
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -566,6 +566,14 @@ pub fn build_pg_insert_sql(
|
|||||||
table: &str,
|
table: &str,
|
||||||
columns: &[(String, serde_json::Value)],
|
columns: &[(String, serde_json::Value)],
|
||||||
) -> (String, Vec<serde_json::Value>) {
|
) -> (String, Vec<serde_json::Value>) {
|
||||||
|
// A table with no editable columns (e.g. only a serial/identity PK) inserts
|
||||||
|
// with all defaults — `INSERT ... () VALUES ()` is invalid SQL.
|
||||||
|
if columns.is_empty() {
|
||||||
|
return (
|
||||||
|
format!("INSERT INTO \"{}\".\"{}\" DEFAULT VALUES", schema, table),
|
||||||
|
vec![],
|
||||||
|
);
|
||||||
|
}
|
||||||
let cols: Vec<String> = columns.iter().map(|(c, _)| format!("\"{}\"", c)).collect();
|
let cols: Vec<String> = columns.iter().map(|(c, _)| format!("\"{}\"", c)).collect();
|
||||||
let mut params: Vec<serde_json::Value> = Vec::new();
|
let mut params: Vec<serde_json::Value> = Vec::new();
|
||||||
let placeholders: Vec<String> = columns
|
let placeholders: Vec<String> = columns
|
||||||
@@ -3102,6 +3110,15 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A table with no editable columns (only a serial/identity PK) must insert
|
||||||
|
/// with `DEFAULT VALUES`, not the invalid `INSERT ... () VALUES ()`.
|
||||||
|
#[test]
|
||||||
|
fn build_pg_insert_sql_empty_columns_uses_default_values() {
|
||||||
|
let (sql, params) = build_pg_insert_sql("public", "t", &[]);
|
||||||
|
assert_eq!(sql, "INSERT INTO \"public\".\"t\" DEFAULT VALUES");
|
||||||
|
assert!(params.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
/// Verify that `get_sqlite_ddl` returns the stored CREATE TABLE statement
|
/// Verify that `get_sqlite_ddl` returns the stored CREATE TABLE statement
|
||||||
/// from `sqlite_master`.
|
/// from `sqlite_master`.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::keychain_acl;
|
||||||
use tauri_plugin_keyring_store::KeyringExt;
|
use tauri_plugin_keyring_store::KeyringExt;
|
||||||
|
|
||||||
/// Store a connection password in the OS keychain.
|
/// Store a connection password in the OS keychain.
|
||||||
@@ -11,7 +12,10 @@ pub fn save_connection_password(
|
|||||||
app.keyring()
|
app.keyring()
|
||||||
.store
|
.store
|
||||||
.set_password(&connection_id, &password)
|
.set_password(&connection_id, &password)
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())?;
|
||||||
|
// Best-effort: pin the item's ACL to this app so re-signed builds never prompt.
|
||||||
|
keychain_acl::after_store(&app, &connection_id);
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieve a connection password from the OS keychain.
|
/// Retrieve a connection password from the OS keychain.
|
||||||
@@ -55,7 +59,9 @@ pub fn save_connection_ssh_password(
|
|||||||
app.keyring()
|
app.keyring()
|
||||||
.store
|
.store
|
||||||
.set_password(&ssh_account("password", &connection_id), &password)
|
.set_password(&ssh_account("password", &connection_id), &password)
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())?;
|
||||||
|
keychain_acl::after_store(&app, &ssh_account("password", &connection_id));
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieve an SSH tunnel password from the OS keychain.
|
/// Retrieve an SSH tunnel password from the OS keychain.
|
||||||
@@ -93,7 +99,9 @@ pub fn save_connection_ssh_passphrase(
|
|||||||
app.keyring()
|
app.keyring()
|
||||||
.store
|
.store
|
||||||
.set_password(&ssh_account("passphrase", &connection_id), &passphrase)
|
.set_password(&ssh_account("passphrase", &connection_id), &passphrase)
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())?;
|
||||||
|
keychain_acl::after_store(&app, &ssh_account("passphrase", &connection_id));
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieve an SSH private-key passphrase from the OS keychain.
|
/// Retrieve an SSH private-key passphrase from the OS keychain.
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
//! Keychain ACL normalization for stored secrets (macOS).
|
||||||
|
//!
|
||||||
|
//! # Why this exists
|
||||||
|
//!
|
||||||
|
//! Generic-password items stored via the legacy keychain API get a default ACL
|
||||||
|
//! that grants access to "the application that created the item". When a Tauri
|
||||||
|
//! app is rebuilt, its code signature changes (dev builds are ad-hoc signed, so
|
||||||
|
//! the identity differs on every build), and the new binary no longer matches
|
||||||
|
//! the item's ACL — macOS then shows the "Gridline wants to access your
|
||||||
|
//! keychain" prompt.
|
||||||
|
//!
|
||||||
|
//! The fix: after storing a secret, replace the item's ACL with an explicit
|
||||||
|
//! `SecAccess` whose trusted-application list contains exactly *this app*,
|
||||||
|
//! resolved by `SecTrustedApplicationCreateFromPath(NULL)` — the current
|
||||||
|
//! process's code-signature requirement. For a Developer ID-signed release
|
||||||
|
//! build that requirement is stable across versions (anchored to the signing
|
||||||
|
//! certificate), so items stay accessible without prompts through upgrades.
|
||||||
|
//!
|
||||||
|
//! # Prompt behavior
|
||||||
|
//!
|
||||||
|
//! - Item created by this build → the ACL set is silent.
|
||||||
|
//! - Item created by an older/differently-signed build → macOS shows the
|
||||||
|
//! standard one-time authorization dialog; "Always Allow" replaces the ACL
|
||||||
|
//! and the prompt never reappears (the same dialog the app would show on the
|
||||||
|
//! first read anyway — this just makes the result permanent and clean).
|
||||||
|
//!
|
||||||
|
//! All operations are best-effort: `after_store` swallows errors so keychain
|
||||||
|
//! writes never fail because of ACL normalization.
|
||||||
|
|
||||||
|
/// Best-effort ACL normalization after a successful keychain write.
|
||||||
|
///
|
||||||
|
/// Resolves the service name the same way `tauri-plugin-keyring-store` does
|
||||||
|
/// (the Tauri bundle identifier) and never fails the caller's operation.
|
||||||
|
pub fn after_store(app: &tauri::AppHandle, account: &str) {
|
||||||
|
let service = app.config().identifier.clone();
|
||||||
|
if let Err(e) = ensure_item_acl(&service, account) {
|
||||||
|
eprintln!("[keychain_acl] skipped for {account}: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Grant this app (by its code-signature requirement) access to a keychain item.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
pub fn ensure_item_acl(service: &str, account: &str) -> Result<(), String> {
|
||||||
|
use core_foundation::array::CFArray;
|
||||||
|
use core_foundation::base::{CFTypeRef, TCFType};
|
||||||
|
use core_foundation::string::CFString;
|
||||||
|
use security_framework::item::{ItemClass, ItemSearchOptions, Reference, SearchResult};
|
||||||
|
use security_framework::os::macos::access::SecAccess;
|
||||||
|
use security_framework_sys::base::SecAccessRef;
|
||||||
|
|
||||||
|
// Locate the item WITHOUT reading its secret: a ref-only lookup
|
||||||
|
// (`kSecReturnRef`, no data/attributes) never triggers an authorization
|
||||||
|
// prompt, so merely probing an item we can't yet access is safe.
|
||||||
|
let found = ItemSearchOptions::new()
|
||||||
|
.class(ItemClass::generic_password())
|
||||||
|
.service(service)
|
||||||
|
.account(account)
|
||||||
|
.load_refs(true)
|
||||||
|
.load_data(false)
|
||||||
|
.load_attributes(false)
|
||||||
|
.search()
|
||||||
|
.map_err(|e| format!("keychain item lookup failed: {e}"))?;
|
||||||
|
|
||||||
|
let item = match found.first() {
|
||||||
|
Some(SearchResult::Ref(Reference::KeychainItem(item))) => item,
|
||||||
|
// No item (yet) or an unexpected result shape — nothing to normalize.
|
||||||
|
_ => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
fn cvt(status: i32) -> Result<(), String> {
|
||||||
|
if status == 0 {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("security framework error {status}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build an access object whose trusted list is exactly this app. A NULL
|
||||||
|
// path resolves the trusted application to the current process's code
|
||||||
|
// signature requirement.
|
||||||
|
unsafe {
|
||||||
|
let mut trusted_app_ref: *mut OpaqueSecTrustedApplication = std::ptr::null_mut();
|
||||||
|
cvt(SecTrustedApplicationCreateFromPath(std::ptr::null(), &mut trusted_app_ref))?;
|
||||||
|
let trusted_app = SecTrustedApplication::wrap_under_create_rule(trusted_app_ref);
|
||||||
|
let trusted_list = CFArray::from_CFTypes(&[trusted_app]);
|
||||||
|
|
||||||
|
let descriptor = CFString::new("Gridline");
|
||||||
|
let mut access_ref: SecAccessRef = std::ptr::null_mut();
|
||||||
|
cvt(SecAccessCreate(
|
||||||
|
descriptor.as_concrete_TypeRef() as CFTypeRef,
|
||||||
|
trusted_list.as_concrete_TypeRef() as CFTypeRef,
|
||||||
|
&mut access_ref,
|
||||||
|
))?;
|
||||||
|
let access = SecAccess::wrap_under_create_rule(access_ref);
|
||||||
|
|
||||||
|
// Replace the item's ACL. Silent when we already own the item;
|
||||||
|
// one-time authorization dialog when the item was created by a
|
||||||
|
// differently-signed build.
|
||||||
|
cvt(SecKeychainItemSetAccess(
|
||||||
|
item.as_concrete_TypeRef(),
|
||||||
|
access.as_concrete_TypeRef(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Non-macOS platforms have no keychain ACL concept — nothing to do.
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
pub fn ensure_item_acl(_service: &str, _account: &str) -> Result<(), String> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
mod macos_ffi {
|
||||||
|
use core_foundation::base::{CFTypeRef, CFTypeID};
|
||||||
|
|
||||||
|
/// Minimal `TCFType` wrapper for `SecTrustedApplication` — the
|
||||||
|
/// security-framework crate exposes no high-level API for it.
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct OpaqueSecTrustedApplication {
|
||||||
|
_priv: [u8; 0],
|
||||||
|
}
|
||||||
|
pub type SecTrustedApplicationRef = *mut OpaqueSecTrustedApplication;
|
||||||
|
|
||||||
|
use core_foundation::{declare_TCFType, impl_TCFType};
|
||||||
|
declare_TCFType!(SecTrustedApplication, SecTrustedApplicationRef);
|
||||||
|
impl_TCFType!(
|
||||||
|
SecTrustedApplication,
|
||||||
|
SecTrustedApplicationRef,
|
||||||
|
SecTrustedApplicationGetTypeID
|
||||||
|
);
|
||||||
|
unsafe impl Send for SecTrustedApplication {}
|
||||||
|
unsafe impl Sync for SecTrustedApplication {}
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
// SecAccess/SecTrustedApplication APIs are deprecated in the macOS SDK
|
||||||
|
// but remain fully functional (the legacy keychain store this app uses
|
||||||
|
// via tauri-plugin-keyring-store relies on the same API family).
|
||||||
|
pub fn SecTrustedApplicationGetTypeID() -> CFTypeID;
|
||||||
|
pub fn SecTrustedApplicationCreateFromPath(
|
||||||
|
path: CFTypeRef,
|
||||||
|
app: *mut SecTrustedApplicationRef,
|
||||||
|
) -> i32;
|
||||||
|
pub fn SecAccessCreate(
|
||||||
|
descriptor: CFTypeRef,
|
||||||
|
trustedlist: CFTypeRef,
|
||||||
|
access: *mut security_framework_sys::base::SecAccessRef,
|
||||||
|
) -> i32;
|
||||||
|
pub fn SecKeychainItemSetAccess(
|
||||||
|
item: security_framework_sys::base::SecKeychainItemRef,
|
||||||
|
access: security_framework_sys::base::SecAccessRef,
|
||||||
|
) -> i32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
use macos_ffi::{
|
||||||
|
OpaqueSecTrustedApplication, SecAccessCreate, SecKeychainItemSetAccess, SecTrustedApplication,
|
||||||
|
SecTrustedApplicationCreateFromPath,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(all(test, target_os = "macos"))]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use security_framework::os::macos::keychain::SecKeychain;
|
||||||
|
use security_framework::os::macos::passwords::find_generic_password;
|
||||||
|
|
||||||
|
/// Real-keychain integration test: creates a generic-password item the
|
||||||
|
/// same way the keyring store does, normalizes its ACL, verifies the
|
||||||
|
/// secret is still readable, and cleans up.
|
||||||
|
///
|
||||||
|
/// Ignored by default (`cargo test`) so CI/dev runs never touch the login
|
||||||
|
/// keychain. Run explicitly with:
|
||||||
|
/// `cargo test -- --ignored keychain_acl`
|
||||||
|
#[test]
|
||||||
|
#[ignore = "touches the real login keychain"]
|
||||||
|
fn keychain_acl_normalizes_item() {
|
||||||
|
let keychain = SecKeychain::default().expect("default keychain");
|
||||||
|
let service = "com.adrianbonpin.gridline.acl-test";
|
||||||
|
let account = format!("acl-test-{}", std::process::id());
|
||||||
|
|
||||||
|
// Create the item exactly like apple-native-keyring-store does
|
||||||
|
// (legacy SecKeychain generic-password API).
|
||||||
|
keychain
|
||||||
|
.set_generic_password(service, &account, b"test-secret")
|
||||||
|
.expect("create test item");
|
||||||
|
|
||||||
|
// ACL normalization must succeed on an item we just created.
|
||||||
|
ensure_item_acl(service, &account).expect("normalize ACL");
|
||||||
|
|
||||||
|
// The item must remain readable with the correct secret.
|
||||||
|
let (password, item) =
|
||||||
|
find_generic_password(Some(&[keychain]), service, &account).expect("read back");
|
||||||
|
assert_eq!(password.as_ref(), b"test-secret");
|
||||||
|
|
||||||
|
// Cleanup: delete the test item.
|
||||||
|
item.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
mod cancel;
|
mod cancel;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod db;
|
mod db;
|
||||||
|
mod keychain_acl;
|
||||||
mod models;
|
mod models;
|
||||||
mod store;
|
mod store;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Gridline",
|
"productName": "Gridline",
|
||||||
"version": "0.7.12",
|
"version": "0.7.15",
|
||||||
"identifier": "com.adrianbonpin.gridline",
|
"identifier": "com.adrianbonpin.gridline",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "bun run dev",
|
"beforeDevCommand": "bun run dev",
|
||||||
@@ -29,7 +29,8 @@
|
|||||||
"resources": ["resources/pg_tools/*", "resources/mysql_tools/*"],
|
"resources": ["resources/pg_tools/*", "resources/mysql_tools/*"],
|
||||||
"macOS": {
|
"macOS": {
|
||||||
"signingIdentity": "-",
|
"signingIdentity": "-",
|
||||||
"hardenedRuntime": false
|
"hardenedRuntime": true,
|
||||||
|
"entitlements": "entitlements.plist"
|
||||||
},
|
},
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
|
|||||||
@@ -231,6 +231,7 @@ export function DbViewerScreen({
|
|||||||
const changesQueue = useDbViewerStore((s) => s.changesQueue);
|
const changesQueue = useDbViewerStore((s) => s.changesQueue);
|
||||||
const tables = useDbViewerStore((s) => s.tables);
|
const tables = useDbViewerStore((s) => s.tables);
|
||||||
const stageCellEdit = useDbViewerStore((s) => s.stageCellEdit);
|
const stageCellEdit = useDbViewerStore((s) => s.stageCellEdit);
|
||||||
|
const updateChange = useDbViewerStore((s) => s.updateChange);
|
||||||
|
|
||||||
const isRunning = activeTab?.tabType === "query" && !!activeTab?.loading;
|
const isRunning = activeTab?.tabType === "query" && !!activeTab?.loading;
|
||||||
|
|
||||||
@@ -339,6 +340,19 @@ export function DbViewerScreen({
|
|||||||
[activeTab, stageCellEdit],
|
[activeTab, stageCellEdit],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleStageInsertCell = useCallback(
|
||||||
|
(changeId: string, column: string, value: string | null) => {
|
||||||
|
const change = useDbViewerStore
|
||||||
|
.getState()
|
||||||
|
.changesQueue.find((c) => c.id === changeId);
|
||||||
|
if (!change) return;
|
||||||
|
const newData = { ...(change.newData ?? {}) };
|
||||||
|
newData[column] = value;
|
||||||
|
updateChange(changeId, { newData });
|
||||||
|
},
|
||||||
|
[updateChange],
|
||||||
|
);
|
||||||
|
|
||||||
const handleOpenRowDetail = useCallback((rowIndex: number) => {
|
const handleOpenRowDetail = useCallback((rowIndex: number) => {
|
||||||
setRowDetailIdx(rowIndex);
|
setRowDetailIdx(rowIndex);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -832,6 +846,24 @@ export function DbViewerScreen({
|
|||||||
// Data is already filtered and sorted server-side; no client-side transform needed.
|
// Data is already filtered and sorted server-side; no client-side transform needed.
|
||||||
const processedRows = rawRows;
|
const processedRows = rawRows;
|
||||||
|
|
||||||
|
// Pending insert rows (from the changes queue) are prepended to the grid as
|
||||||
|
// editable rows. Only pending inserts for the active table tab count.
|
||||||
|
const pendingInserts =
|
||||||
|
activeTab?.tabType === "table"
|
||||||
|
? changesQueue.filter(
|
||||||
|
(c) =>
|
||||||
|
c.type === "insert" &&
|
||||||
|
c.status === "pending" &&
|
||||||
|
c.schema === activeTab.schema &&
|
||||||
|
c.table === activeTab.table,
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const pendingInsertChangeIds = pendingInserts.map((c) => c.id);
|
||||||
|
const pendingInsertRows = pendingInserts.map((c) =>
|
||||||
|
columns.map((col) => c.newData?.[col.name] ?? null),
|
||||||
|
);
|
||||||
|
const displayRows = [...pendingInsertRows, ...processedRows];
|
||||||
|
|
||||||
const onPanelResizeStart = useCallback(
|
const onPanelResizeStart = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -1184,7 +1216,7 @@ const onQueriesPanelResizeStart = useCallback(
|
|||||||
connectionId={connectionId}
|
connectionId={connectionId}
|
||||||
schema={activeSchema}
|
schema={activeSchema}
|
||||||
table={activeTable}
|
table={activeTable}
|
||||||
rows={processedRows}
|
rows={displayRows}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
hiddenColumns={hiddenColumns}
|
hiddenColumns={hiddenColumns}
|
||||||
selectedRows={selectedRows}
|
selectedRows={selectedRows}
|
||||||
@@ -1192,6 +1224,7 @@ const onQueriesPanelResizeStart = useCallback(
|
|||||||
tabType={activeTab?.tabType ?? "table"}
|
tabType={activeTab?.tabType ?? "table"}
|
||||||
getLocator={getLocator}
|
getLocator={getLocator}
|
||||||
onStageEdit={readOnlyTable ? undefined : handleStageEdit}
|
onStageEdit={readOnlyTable ? undefined : handleStageEdit}
|
||||||
|
onStageInsertCell={readOnlyTable ? undefined : handleStageInsertCell}
|
||||||
onOpenRowDetail={handleOpenRowDetail}
|
onOpenRowDetail={handleOpenRowDetail}
|
||||||
readOnly={readOnlyTable}
|
readOnly={readOnlyTable}
|
||||||
enumValues={editorOptions?.enums}
|
enumValues={editorOptions?.enums}
|
||||||
@@ -1199,6 +1232,7 @@ const onQueriesPanelResizeStart = useCallback(
|
|||||||
fkPlaceholders={editorOptions?.fkPlaceholders}
|
fkPlaceholders={editorOptions?.fkPlaceholders}
|
||||||
stagedValues={stagedValues}
|
stagedValues={stagedValues}
|
||||||
pendingKeys={pendingKeys}
|
pendingKeys={pendingKeys}
|
||||||
|
pendingInsertChangeIds={pendingInsertChangeIds}
|
||||||
onToggleRow={(rowIndex) => {
|
onToggleRow={(rowIndex) => {
|
||||||
setSelectedRows(
|
setSelectedRows(
|
||||||
(prev) => {
|
(prev) => {
|
||||||
@@ -1348,7 +1382,7 @@ const onQueriesPanelResizeStart = useCallback(
|
|||||||
connectionId={connectionId}
|
connectionId={connectionId}
|
||||||
schema={activeSchema}
|
schema={activeSchema}
|
||||||
table={activeTable}
|
table={activeTable}
|
||||||
rows={processedRows}
|
rows={displayRows}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
hiddenColumns={hiddenColumns}
|
hiddenColumns={hiddenColumns}
|
||||||
selectedRows={selectedRows}
|
selectedRows={selectedRows}
|
||||||
@@ -1356,6 +1390,7 @@ const onQueriesPanelResizeStart = useCallback(
|
|||||||
tabType={activeTab?.tabType ?? "table"}
|
tabType={activeTab?.tabType ?? "table"}
|
||||||
getLocator={getLocator}
|
getLocator={getLocator}
|
||||||
onStageEdit={readOnlyTable ? undefined : handleStageEdit}
|
onStageEdit={readOnlyTable ? undefined : handleStageEdit}
|
||||||
|
onStageInsertCell={readOnlyTable ? undefined : handleStageInsertCell}
|
||||||
onOpenRowDetail={handleOpenRowDetail}
|
onOpenRowDetail={handleOpenRowDetail}
|
||||||
readOnly={readOnlyTable}
|
readOnly={readOnlyTable}
|
||||||
enumValues={editorOptions?.enums}
|
enumValues={editorOptions?.enums}
|
||||||
@@ -1363,6 +1398,7 @@ const onQueriesPanelResizeStart = useCallback(
|
|||||||
fkPlaceholders={editorOptions?.fkPlaceholders}
|
fkPlaceholders={editorOptions?.fkPlaceholders}
|
||||||
stagedValues={stagedValues}
|
stagedValues={stagedValues}
|
||||||
pendingKeys={pendingKeys}
|
pendingKeys={pendingKeys}
|
||||||
|
pendingInsertChangeIds={pendingInsertChangeIds}
|
||||||
onToggleRow={(rowIndex) => {
|
onToggleRow={(rowIndex) => {
|
||||||
setSelectedRows((prev) => {
|
setSelectedRows((prev) => {
|
||||||
const next = new Set(
|
const next = new Set(
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
|
||||||
import { FilterBuilder } from "./FilterBuilder";
|
|
||||||
import type { ColumnInfo } from "../../lib/types";
|
|
||||||
|
|
||||||
const cols: ColumnInfo[] = [
|
|
||||||
{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: false, is_generated: false },
|
|
||||||
{ name: "name", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
|
|
||||||
];
|
|
||||||
|
|
||||||
describe("FilterBuilder", () => {
|
|
||||||
it("drops a column chip into the drop zone to create a rule with a type-aware operator", () => {
|
|
||||||
const onChange = vi.fn();
|
|
||||||
render(<FilterBuilder columns={cols} rules={[]} onChange={onChange} />);
|
|
||||||
fireEvent.click(screen.getByText("name"));
|
|
||||||
expect(onChange).toHaveBeenCalledWith([
|
|
||||||
expect.objectContaining({ column: "name", operator: "contains" }),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
it("removing a rule calls onChange without it", () => {
|
|
||||||
const onChange = vi.fn();
|
|
||||||
const rules = [{ id: "r1", column: "name", operator: "contains" as const, value: "Al" }];
|
|
||||||
render(<FilterBuilder columns={cols} rules={rules} onChange={onChange} />);
|
|
||||||
fireEvent.click(screen.getByLabelText("Remove filter name"));
|
|
||||||
expect(onChange).toHaveBeenCalledWith([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { DndContext, useDraggable, useDroppable } from "@dnd-kit/core";
|
|
||||||
import { X } from "lucide-react";
|
|
||||||
import { defaultFilterOperator } from "../grid/gridEditability";
|
|
||||||
import type { ColumnInfo } from "../../lib/types";
|
|
||||||
import type { FilterRule, FilterOperator } from "../../stores/dbViewerStore";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
columns: ColumnInfo[];
|
|
||||||
rules: FilterRule[];
|
|
||||||
onChange: (rules: FilterRule[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Chip({ col, onAdd }: { col: ColumnInfo; onAdd: () => void }) {
|
|
||||||
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
|
|
||||||
id: `col-${col.name}`,
|
|
||||||
data: { column: col },
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
ref={setNodeRef}
|
|
||||||
{...attributes}
|
|
||||||
{...listeners}
|
|
||||||
onClick={onAdd}
|
|
||||||
className={`px-2 py-1 text-xs rounded border border-border bg-surface text-text hover:border-accent ${
|
|
||||||
isDragging ? "opacity-50" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{col.name}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const OPERATORS: FilterOperator[] = [
|
|
||||||
"eq",
|
|
||||||
"neq",
|
|
||||||
"contains",
|
|
||||||
"starts",
|
|
||||||
"ends",
|
|
||||||
"gt",
|
|
||||||
"lt",
|
|
||||||
"null",
|
|
||||||
"notnull",
|
|
||||||
];
|
|
||||||
|
|
||||||
export function FilterBuilder({ columns, rules, onChange }: Props) {
|
|
||||||
const [val, setVal] = useState<Record<string, string>>({});
|
|
||||||
|
|
||||||
const addRule = (col: ColumnInfo) => {
|
|
||||||
const op = defaultFilterOperator(col.data_type);
|
|
||||||
onChange([
|
|
||||||
...rules,
|
|
||||||
{
|
|
||||||
id: `f-${Date.now()}-${col.name}`,
|
|
||||||
column: col.name,
|
|
||||||
operator: op,
|
|
||||||
value: "",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const { setNodeRef, isOver } = useDroppable({ id: "filter-dropzone" });
|
|
||||||
|
|
||||||
const remove = (id: string) => onChange(rules.filter((r) => r.id !== id));
|
|
||||||
const update = (id: string, patch: Partial<FilterRule>) =>
|
|
||||||
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DndContext
|
|
||||||
onDragEnd={(e) => {
|
|
||||||
const id = e.active.id as string;
|
|
||||||
const colName = id.replace(/^col-/, "");
|
|
||||||
const col = columns.find((c) => c.name === colName);
|
|
||||||
if (col && e.over?.id === "filter-dropzone") addRule(col);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex flex-wrap gap-1 mb-2">
|
|
||||||
{columns.map((c) => (
|
|
||||||
<Chip key={c.name} col={c} onAdd={() => addRule(c)} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
ref={setNodeRef}
|
|
||||||
className={`min-h-[40px] border border-dashed rounded p-2 space-y-1 ${
|
|
||||||
isOver ? "border-accent bg-surface" : "border-border"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{rules.length === 0 && (
|
|
||||||
<span className="text-xs text-text-muted">
|
|
||||||
Drop columns here to add filters
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{rules.map((r) => (
|
|
||||||
<div key={r.id} className="flex items-center gap-2 text-xs">
|
|
||||||
<span className="text-text font-semibold">{r.column}</span>
|
|
||||||
<select
|
|
||||||
value={r.operator}
|
|
||||||
onChange={(e) =>
|
|
||||||
update(r.id, { operator: e.target.value as FilterOperator })
|
|
||||||
}
|
|
||||||
className="bg-surface border border-border rounded px-1"
|
|
||||||
>
|
|
||||||
{OPERATORS.map((o) => (
|
|
||||||
<option key={o} value={o}>
|
|
||||||
{o}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{!["null", "notnull"].includes(r.operator) && (
|
|
||||||
<input
|
|
||||||
value={val[r.id] ?? r.value}
|
|
||||||
onChange={(e) => {
|
|
||||||
setVal({ ...val, [r.id]: e.target.value });
|
|
||||||
update(r.id, { value: e.target.value });
|
|
||||||
}}
|
|
||||||
className="bg-surface border border-border rounded px-1 flex-1"
|
|
||||||
placeholder="value"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
aria-label={`Remove filter ${r.column}`}
|
|
||||||
onClick={() => remove(r.id)}
|
|
||||||
className="text-text-muted hover:text-red-400"
|
|
||||||
>
|
|
||||||
<X size={12} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</DndContext>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -99,7 +99,7 @@ export function SchemaMenu({ connectionId, schema, onRefresh }: SchemaMenuProps)
|
|||||||
"w-full bg-surface border border-border rounded-md px-3 py-2 text-sm text-text placeholder:text-text-muted outline-none focus:border-accent/50 transition-colors";
|
"w-full bg-surface border border-border rounded-md px-3 py-2 text-sm text-text placeholder:text-text-muted outline-none focus:border-accent/50 transition-colors";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1" ref={menuRef}>
|
<div className="relative flex items-center gap-1" ref={menuRef}>
|
||||||
<button
|
<button
|
||||||
aria-label="New schema"
|
aria-label="New schema"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -123,7 +123,7 @@ export function SchemaMenu({ connectionId, schema, onRefresh }: SchemaMenuProps)
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<div className="absolute right-0 top-8 mt-1 rounded-xl bg-surface border border-border py-1 z-20 min-w-[160px] shadow-lg">
|
<div className="absolute left-0 top-8 mt-1 rounded-xl bg-surface border border-border py-1 z-20 min-w-[160px] shadow-lg">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -325,13 +325,115 @@ describe("TableControls", () => {
|
|||||||
expect(screen.queryByLabelText(/insert row/i)).toBeNull();
|
expect(screen.queryByLabelText(/insert row/i)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the FilterBuilder inside the filter popover", () => {
|
it("stages an insert with only editable columns when Insert Row is clicked", () => {
|
||||||
|
const cols = [
|
||||||
|
{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: "nextval('users_id_seq')", editable: false, is_generated: false },
|
||||||
|
{ name: "name", data_type: "text", is_nullable: false, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
|
||||||
|
{ name: "created_at", data_type: "timestamp", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: "now()", editable: true, is_generated: false },
|
||||||
|
];
|
||||||
|
seed([makeTab({ data: { columns: cols, rows: [], total_rows: 0, page: 1, page_size: 50 } })], "tab-1");
|
||||||
|
renderControls({ columns: cols, rows: [] });
|
||||||
|
fireEvent.click(screen.getByLabelText(/insert row/i));
|
||||||
|
const queue = useDbViewerStore.getState().changesQueue;
|
||||||
|
expect(queue).toHaveLength(1);
|
||||||
|
expect(queue[0].type).toBe("insert");
|
||||||
|
expect(queue[0].newData).toEqual({ name: null, created_at: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the column filter popover with the + Add filter action", () => {
|
||||||
seed([makeTab()], "tab-1");
|
seed([makeTab()], "tab-1");
|
||||||
renderControls();
|
renderControls();
|
||||||
fireEvent.click(screen.getByLabelText(/column filters/i));
|
fireEvent.click(screen.getByLabelText(/column filters/i));
|
||||||
expect(
|
expect(screen.getByText("Column Filters")).toBeInTheDocument();
|
||||||
screen.getByText("Drop columns here to add filters"),
|
expect(screen.getByText("+ Add filter")).toBeInTheDocument();
|
||||||
).toBeInTheDocument();
|
});
|
||||||
|
|
||||||
|
it("debounces filter value edits so a query only fires after the user stops typing", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
seed([makeTab()], "tab-1");
|
||||||
|
const onFilterChange = vi.fn();
|
||||||
|
renderControls({
|
||||||
|
onFilterChange,
|
||||||
|
filterRules: [
|
||||||
|
{ id: "r1", column: "id", operator: "contains", value: "" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText(/column filters/i));
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText("value");
|
||||||
|
fireEvent.change(input, { target: { value: "a" } });
|
||||||
|
fireEvent.change(input, { target: { value: "ab" } });
|
||||||
|
fireEvent.change(input, { target: { value: "abc" } });
|
||||||
|
|
||||||
|
// No commit yet — the debounce hasn't elapsed.
|
||||||
|
expect(onFilterChange).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only the final value is committed, once.
|
||||||
|
expect(onFilterChange).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onFilterChange).toHaveBeenCalledWith([
|
||||||
|
expect.objectContaining({ value: "abc" }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the filter popover when the button is clicked while it is open", () => {
|
||||||
|
seed([makeTab()], "tab-1");
|
||||||
|
renderControls();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText(/column filters/i));
|
||||||
|
expect(screen.getByText("Column Filters")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Clicking the button while open closes it (plain toggle).
|
||||||
|
fireEvent.click(screen.getByLabelText(/column filters/i));
|
||||||
|
expect(screen.queryByText("Column Filters")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles the sort rules popover open and closed", () => {
|
||||||
|
seed([makeTab()], "tab-1");
|
||||||
|
renderControls();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText(/sort rules/i));
|
||||||
|
expect(screen.getByText("Sort Rules")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText(/sort rules/i));
|
||||||
|
expect(screen.queryByText("Sort Rules")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles the auto-refresh dropdown open and closed", () => {
|
||||||
|
seed([makeTab()], "tab-1");
|
||||||
|
renderControls();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText(/auto-refresh/i));
|
||||||
|
expect(screen.getByText("Off")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText(/auto-refresh/i));
|
||||||
|
expect(screen.queryByText("Off")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles the show/hide columns dropdown open and closed", () => {
|
||||||
|
seed([makeTab()], "tab-1");
|
||||||
|
renderControls();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText(/toggle columns/i));
|
||||||
|
expect(screen.getByText("Visible columns")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText(/toggle columns/i));
|
||||||
|
expect(screen.queryByText("Visible columns")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles the export dropdown open and closed", () => {
|
||||||
|
seed([makeTab()], "tab-1");
|
||||||
|
renderControls();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText(/export/i));
|
||||||
|
expect(screen.getByText("JSON")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText(/export/i));
|
||||||
|
expect(screen.queryByText("JSON")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("export dropdown includes the Excel option", () => {
|
it("export dropdown includes the Excel option", () => {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useDbViewerStore, type FilterRule, type SortRule } from "../../stores/dbViewerStore";
|
import { useDbViewerStore, type FilterRule, type SortRule } from "../../stores/dbViewerStore";
|
||||||
import { useNotificationStore } from "../../stores/notificationStore";
|
import { useNotificationStore } from "../../stores/notificationStore";
|
||||||
import { FilterBuilder } from "./FilterBuilder";
|
|
||||||
import { Tooltip } from "../ui/Tooltip";
|
import { Tooltip } from "../ui/Tooltip";
|
||||||
import { exportData } from "../../lib/exportData";
|
import { exportData } from "../../lib/exportData";
|
||||||
import type { ColumnInfo } from "../../lib/types";
|
import type { ColumnInfo } from "../../lib/types";
|
||||||
@@ -49,22 +48,28 @@ function DropdownMenu({
|
|||||||
open,
|
open,
|
||||||
setOpen,
|
setOpen,
|
||||||
align,
|
align,
|
||||||
|
wrapRef,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
setOpen: (v: boolean) => void;
|
setOpen: (v: boolean) => void;
|
||||||
align?: "left" | "right";
|
align?: "left" | "right";
|
||||||
|
/** When provided, outside-click detection uses this wrapper (which should
|
||||||
|
* contain both the toggle button and the menu) so the button never fights
|
||||||
|
* the outside-click handler. */
|
||||||
|
wrapRef?: React.RefObject<HTMLDivElement | null>;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const close = (e: MouseEvent) => {
|
const close = (e: MouseEvent) => {
|
||||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
const container = wrapRef?.current ?? ref.current;
|
||||||
|
if (container && !container.contains(e.target as Node)) setOpen(false);
|
||||||
};
|
};
|
||||||
document.addEventListener("mousedown", close);
|
document.addEventListener("mousedown", close);
|
||||||
return () => document.removeEventListener("mousedown", close);
|
return () => document.removeEventListener("mousedown", close);
|
||||||
}, [open, setOpen]);
|
}, [open, setOpen, wrapRef]);
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
return (
|
return (
|
||||||
@@ -79,28 +84,84 @@ function DropdownMenu({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounced free-text input for filter values. The value is committed to the
|
||||||
|
* rules (and thus triggers a server-side refetch) only after the user stops
|
||||||
|
* typing for `DEBOUNCE_MS`. Without this, every keystroke fires a query and
|
||||||
|
* the in-flight guard in `fetchData` silently drops intermediate fetches, so
|
||||||
|
* the final filter value can end up not being applied.
|
||||||
|
*/
|
||||||
|
const FILTER_DEBOUNCE_MS = 400;
|
||||||
|
|
||||||
|
function DebouncedValueInput({
|
||||||
|
value,
|
||||||
|
onCommit,
|
||||||
|
placeholder,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onCommit: (v: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}) {
|
||||||
|
const [draft, setDraft] = useState(value);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Keep the draft in sync when the committed value changes externally
|
||||||
|
// (e.g. a rule is replaced or removed while this input is mounted).
|
||||||
|
useEffect(() => {
|
||||||
|
setDraft(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
setDraft(v);
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = setTimeout(() => onCommit(v), FILTER_DEBOUNCE_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={draft}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="flex-1 rounded border border-border bg-surface text-xs px-1.5 py-1 text-text min-w-0"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function FilterModal({
|
function FilterModal({
|
||||||
columns,
|
columns,
|
||||||
rules,
|
rules,
|
||||||
onChange,
|
onChange,
|
||||||
open,
|
open,
|
||||||
setOpen,
|
setOpen,
|
||||||
|
wrapRef,
|
||||||
}: {
|
}: {
|
||||||
columns: ColumnInfo[];
|
columns: ColumnInfo[];
|
||||||
rules: FilterRule[];
|
rules: FilterRule[];
|
||||||
onChange: (rules: FilterRule[]) => void;
|
onChange: (rules: FilterRule[]) => void;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
setOpen: (v: boolean) => void;
|
setOpen: (v: boolean) => void;
|
||||||
|
wrapRef: React.RefObject<HTMLDivElement | null>;
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
// Close on outside click, but treat the toggle button (which lives inside
|
||||||
|
// the same wrapper) as part of the modal so it never fights the toggle.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const close = (e: MouseEvent) => {
|
const close = (e: MouseEvent) => {
|
||||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener("mousedown", close);
|
document.addEventListener("mousedown", close);
|
||||||
return () => document.removeEventListener("mousedown", close);
|
return () => document.removeEventListener("mousedown", close);
|
||||||
}, [open, setOpen]);
|
}, [open, setOpen, wrapRef]);
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
@@ -116,17 +177,13 @@ function FilterModal({
|
|||||||
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="absolute top-full left-0 mt-1 z-30 w-96 rounded-lg bg-surface border border-border shadow-lg p-3">
|
||||||
ref={ref}
|
|
||||||
className="absolute top-full left-0 mt-1 z-30 w-96 rounded-lg bg-surface border border-border shadow-lg p-3"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-xs font-semibold text-text">Column Filters</span>
|
<span className="text-xs font-semibold text-text">Column Filters</span>
|
||||||
<button type="button" onClick={() => setOpen(false)} className="text-text-muted hover:text-text cursor-pointer">
|
<button type="button" onClick={() => setOpen(false)} className="text-text-muted hover:text-text cursor-pointer">
|
||||||
<X size={14} />
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<FilterBuilder columns={columns} rules={rules} onChange={onChange} />
|
|
||||||
{rules.map((rule) => (
|
{rules.map((rule) => (
|
||||||
<div key={rule.id} className="flex items-center gap-1.5 mb-1.5">
|
<div key={rule.id} className="flex items-center gap-1.5 mb-1.5">
|
||||||
<select
|
<select
|
||||||
@@ -152,12 +209,10 @@ function FilterModal({
|
|||||||
<option value="notnull">not null</option>
|
<option value="notnull">not null</option>
|
||||||
</select>
|
</select>
|
||||||
{rule.operator !== "null" && rule.operator !== "notnull" && (
|
{rule.operator !== "null" && rule.operator !== "notnull" && (
|
||||||
<input
|
<DebouncedValueInput
|
||||||
type="text"
|
|
||||||
value={rule.value}
|
value={rule.value}
|
||||||
onChange={(e) => updateRule(rule.id, { value: e.target.value })}
|
onCommit={(v) => updateRule(rule.id, { value: v })}
|
||||||
placeholder="value"
|
placeholder="value"
|
||||||
className="flex-1 rounded border border-border bg-surface text-xs px-1.5 py-1 text-text min-w-0"
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<button type="button" onClick={() => removeRule(rule.id)} className="text-text-muted hover:text-red-400 shrink-0 cursor-pointer">
|
<button type="button" onClick={() => removeRule(rule.id)} className="text-text-muted hover:text-red-400 shrink-0 cursor-pointer">
|
||||||
@@ -182,22 +237,27 @@ function SortModal({
|
|||||||
onChange,
|
onChange,
|
||||||
open,
|
open,
|
||||||
setOpen,
|
setOpen,
|
||||||
|
wrapRef,
|
||||||
}: {
|
}: {
|
||||||
columns: ColumnInfo[];
|
columns: ColumnInfo[];
|
||||||
rules: SortRule[];
|
rules: SortRule[];
|
||||||
onChange: (rules: SortRule[]) => void;
|
onChange: (rules: SortRule[]) => void;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
setOpen: (v: boolean) => void;
|
setOpen: (v: boolean) => void;
|
||||||
|
wrapRef: React.RefObject<HTMLDivElement | null>;
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
// Close on outside click, but treat the toggle button (which lives inside
|
||||||
|
// the same wrapper) as part of the modal so it never fights the toggle.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const close = (e: MouseEvent) => {
|
const close = (e: MouseEvent) => {
|
||||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener("mousedown", close);
|
document.addEventListener("mousedown", close);
|
||||||
return () => document.removeEventListener("mousedown", close);
|
return () => document.removeEventListener("mousedown", close);
|
||||||
}, [open, setOpen]);
|
}, [open, setOpen, wrapRef]);
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
@@ -213,10 +273,7 @@ function SortModal({
|
|||||||
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="absolute top-full left-0 mt-1 z-30 w-72 rounded-lg bg-surface border border-border shadow-lg p-3">
|
||||||
ref={ref}
|
|
||||||
className="absolute top-full left-0 mt-1 z-30 w-72 rounded-lg bg-surface border border-border shadow-lg p-3"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-xs font-semibold text-text">Sort Rules</span>
|
<span className="text-xs font-semibold text-text">Sort Rules</span>
|
||||||
<button type="button" onClick={() => setOpen(false)} className="text-text-muted hover:text-text cursor-pointer">
|
<button type="button" onClick={() => setOpen(false)} className="text-text-muted hover:text-text cursor-pointer">
|
||||||
@@ -272,6 +329,7 @@ function BulkActionsDropdown({
|
|||||||
onClearSelection: () => void;
|
onClearSelection: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const wrapRef = useRef<HTMLDivElement>(null);
|
||||||
const addChange = useDbViewerStore((s) => s.addChange);
|
const addChange = useDbViewerStore((s) => s.addChange);
|
||||||
|
|
||||||
const copyToClipboard = (text: string) => {
|
const copyToClipboard = (text: string) => {
|
||||||
@@ -338,7 +396,7 @@ function BulkActionsDropdown({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative" ref={wrapRef}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
@@ -347,7 +405,7 @@ function BulkActionsDropdown({
|
|||||||
<span className="text-xs font-medium">Actions</span>
|
<span className="text-xs font-medium">Actions</span>
|
||||||
<ChevronDown size={12} />
|
<ChevronDown size={12} />
|
||||||
</button>
|
</button>
|
||||||
<DropdownMenu open={open} setOpen={setOpen} align="right">
|
<DropdownMenu open={open} setOpen={setOpen} align="right" wrapRef={wrapRef}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCopyJSON}
|
onClick={handleCopyJSON}
|
||||||
@@ -445,11 +503,16 @@ export function TableControls({
|
|||||||
|
|
||||||
// local state
|
// local state
|
||||||
const [filterOpen, setFilterOpen] = useState(false);
|
const [filterOpen, setFilterOpen] = useState(false);
|
||||||
|
const filterWrapRef = useRef<HTMLDivElement>(null);
|
||||||
const [sortOpen, setSortOpen] = useState(false);
|
const [sortOpen, setSortOpen] = useState(false);
|
||||||
|
const sortWrapRef = useRef<HTMLDivElement>(null);
|
||||||
const [columnMenuOpen, setColumnMenuOpen] = useState(false);
|
const [columnMenuOpen, setColumnMenuOpen] = useState(false);
|
||||||
|
const columnMenuWrapRef = useRef<HTMLDivElement>(null);
|
||||||
const [exportOpen, setExportOpen] = useState(false);
|
const [exportOpen, setExportOpen] = useState(false);
|
||||||
|
const exportWrapRef = useRef<HTMLDivElement>(null);
|
||||||
const [autoRefresh, setAutoRefresh] = useState(defaultRefreshRate);
|
const [autoRefresh, setAutoRefresh] = useState(defaultRefreshRate);
|
||||||
const [autoRefreshOpen, setAutoRefreshOpen] = useState(false);
|
const [autoRefreshOpen, setAutoRefreshOpen] = useState(false);
|
||||||
|
const autoRefreshWrapRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Auto-refresh: a self-restarting timer that only counts down while the tab
|
// Auto-refresh: a self-restarting timer that only counts down while the tab
|
||||||
// is idle. Fires a refresh, waits for it to complete (loading → false),
|
// is idle. Fires a refresh, waits for it to complete (loading → false),
|
||||||
@@ -484,8 +547,14 @@ export function TableControls({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleInsertRow = () => {
|
const handleInsertRow = () => {
|
||||||
|
// Build the insert template from editable columns only. PK / generated /
|
||||||
|
// identity columns are omitted so their defaults (serial, identity,
|
||||||
|
// generated) apply on commit — an explicit NULL would bypass them and
|
||||||
|
// fail with a NOT NULL violation.
|
||||||
const newData: Record<string, unknown> = {};
|
const newData: Record<string, unknown> = {};
|
||||||
columns.forEach((c) => { newData[c.name] = null; });
|
columns.forEach((c) => {
|
||||||
|
if (c.editable && !c.is_pk && !c.is_generated) newData[c.name] = null;
|
||||||
|
});
|
||||||
addChange({
|
addChange({
|
||||||
type: "insert",
|
type: "insert",
|
||||||
schema,
|
schema,
|
||||||
@@ -545,7 +614,7 @@ export function TableControls({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const exportControl = (
|
const exportControl = (
|
||||||
<div className="relative">
|
<div className="relative" ref={exportWrapRef}>
|
||||||
<Tooltip content="Export" side="bottom">
|
<Tooltip content="Export" side="bottom">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -556,7 +625,7 @@ export function TableControls({
|
|||||||
<Download size={14} />
|
<Download size={14} />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<DropdownMenu open={exportOpen} setOpen={setExportOpen}>
|
<DropdownMenu open={exportOpen} setOpen={setExportOpen} wrapRef={exportWrapRef}>
|
||||||
{EXPORT_FORMATS.map((fmt) => (
|
{EXPORT_FORMATS.map((fmt) => (
|
||||||
<button
|
<button
|
||||||
key={fmt.ext}
|
key={fmt.ext}
|
||||||
@@ -572,7 +641,7 @@ export function TableControls({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const columnsControl = (
|
const columnsControl = (
|
||||||
<div className="relative">
|
<div className="relative" ref={columnMenuWrapRef}>
|
||||||
<Tooltip content="Show/hide columns" side="bottom">
|
<Tooltip content="Show/hide columns" side="bottom">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -587,6 +656,7 @@ export function TableControls({
|
|||||||
open={columnMenuOpen}
|
open={columnMenuOpen}
|
||||||
setOpen={setColumnMenuOpen}
|
setOpen={setColumnMenuOpen}
|
||||||
align={isQuery ? "left" : "right"}
|
align={isQuery ? "left" : "right"}
|
||||||
|
wrapRef={columnMenuWrapRef}
|
||||||
>
|
>
|
||||||
<div className="px-2 py-1 text-[10px] text-text-muted uppercase tracking-wider">
|
<div className="px-2 py-1 text-[10px] text-text-muted uppercase tracking-wider">
|
||||||
Visible columns
|
Visible columns
|
||||||
@@ -658,7 +728,7 @@ export function TableControls({
|
|||||||
{refreshControl}
|
{refreshControl}
|
||||||
|
|
||||||
{/* Auto-refresh */}
|
{/* Auto-refresh */}
|
||||||
<div className="relative">
|
<div className="relative" ref={autoRefreshWrapRef}>
|
||||||
<Tooltip content={`Auto-refresh: ${autoRefresh > 0 ? `${autoRefresh / 1000}s` : "Off"}`} side="bottom">
|
<Tooltip content={`Auto-refresh: ${autoRefresh > 0 ? `${autoRefresh / 1000}s` : "Off"}`} side="bottom">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -672,7 +742,7 @@ export function TableControls({
|
|||||||
{autoRefresh > 0 && <span className="text-[10px] font-medium">{autoRefresh / 1000}s</span>}
|
{autoRefresh > 0 && <span className="text-[10px] font-medium">{autoRefresh / 1000}s</span>}
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<DropdownMenu open={autoRefreshOpen} setOpen={setAutoRefreshOpen}>
|
<DropdownMenu open={autoRefreshOpen} setOpen={setAutoRefreshOpen} wrapRef={autoRefreshWrapRef}>
|
||||||
{AUTO_REFRESH_OPTIONS.map((opt) => (
|
{AUTO_REFRESH_OPTIONS.map((opt) => (
|
||||||
<button
|
<button
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
@@ -692,7 +762,7 @@ export function TableControls({
|
|||||||
<div className="w-px h-4 bg-border mx-1" />
|
<div className="w-px h-4 bg-border mx-1" />
|
||||||
|
|
||||||
{/* Filter */}
|
{/* Filter */}
|
||||||
<div className="relative">
|
<div className="relative" ref={filterWrapRef}>
|
||||||
<Tooltip content="Column filters" side="bottom">
|
<Tooltip content="Column filters" side="bottom">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -716,11 +786,12 @@ export function TableControls({
|
|||||||
onChange={onFilterChange}
|
onChange={onFilterChange}
|
||||||
open={filterOpen}
|
open={filterOpen}
|
||||||
setOpen={setFilterOpen}
|
setOpen={setFilterOpen}
|
||||||
|
wrapRef={filterWrapRef}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sort */}
|
{/* Sort */}
|
||||||
<div className="relative">
|
<div className="relative" ref={sortWrapRef}>
|
||||||
<Tooltip content="Sort rules" side="bottom">
|
<Tooltip content="Sort rules" side="bottom">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -744,6 +815,7 @@ export function TableControls({
|
|||||||
onChange={onSortChange}
|
onChange={onSortChange}
|
||||||
open={sortOpen}
|
open={sortOpen}
|
||||||
setOpen={setSortOpen}
|
setOpen={setSortOpen}
|
||||||
|
wrapRef={sortWrapRef}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -6,16 +6,18 @@ interface Props {
|
|||||||
isJson: boolean;
|
isJson: boolean;
|
||||||
isFk: boolean;
|
isFk: boolean;
|
||||||
nullable: boolean;
|
nullable: boolean;
|
||||||
|
/** When true, hide row-scoped actions (View Row / Select Row) — pending insert rows have no backing row. */
|
||||||
|
isInsertRow?: boolean;
|
||||||
onCopy: () => void; onCopyJson: () => void; onEdit: () => void;
|
onCopy: () => void; onCopyJson: () => void; onEdit: () => void;
|
||||||
onSetNull: () => void; onOpenFk: () => void; onClose: () => void;
|
onSetNull: () => void; onOpenFk: () => void; onClose: () => void;
|
||||||
onViewRow: () => void; onSelectRow: () => void;
|
onViewRow: () => void; onSelectRow: () => void;
|
||||||
}
|
}
|
||||||
export function CellContextMenu({ anchorRect, editable, isJson, isFk, nullable, onCopy, onCopyJson, onEdit, onSetNull, onOpenFk, onClose, onViewRow, onSelectRow }: Props) {
|
export function CellContextMenu({ anchorRect, editable, isJson, isFk, nullable, isInsertRow = false, onCopy, onCopyJson, onEdit, onSetNull, onOpenFk, onClose, onViewRow, onSelectRow }: Props) {
|
||||||
const items: { label: string; icon: React.ReactNode; onClick: () => void; show: boolean }[] = [
|
const items: { label: string; icon: React.ReactNode; onClick: () => void; show: boolean }[] = [
|
||||||
{ label: "Copy", icon: <Copy size={12} />, onClick: () => { onCopy(); onClose(); }, show: true },
|
{ label: "Copy", icon: <Copy size={12} />, onClick: () => { onCopy(); onClose(); }, show: true },
|
||||||
{ label: "Copy JSON", icon: <Braces size={12} />, onClick: () => { onCopyJson(); onClose(); }, show: isJson },
|
{ label: "Copy JSON", icon: <Braces size={12} />, onClick: () => { onCopyJson(); onClose(); }, show: isJson },
|
||||||
{ label: "View Row", icon: <Eye size={12} />, onClick: () => { onViewRow(); onClose(); }, show: true },
|
{ label: "View Row", icon: <Eye size={12} />, onClick: () => { onViewRow(); onClose(); }, show: !isInsertRow },
|
||||||
{ label: "Select Row", icon: <MousePointerClick size={12} />, onClick: () => { onSelectRow(); onClose(); }, show: true },
|
{ label: "Select Row", icon: <MousePointerClick size={12} />, onClick: () => { onSelectRow(); onClose(); }, show: !isInsertRow },
|
||||||
{ label: "Edit", icon: <Pencil size={12} />, onClick: () => { onEdit(); onClose(); }, show: editable },
|
{ label: "Edit", icon: <Pencil size={12} />, onClick: () => { onEdit(); onClose(); }, show: editable },
|
||||||
{ label: "Set NULL", icon: <CircleSlash size={12} />, onClick: () => { onSetNull(); onClose(); }, show: editable && nullable },
|
{ label: "Set NULL", icon: <CircleSlash size={12} />, onClick: () => { onSetNull(); onClose(); }, show: editable && nullable },
|
||||||
{ label: "Open FK reference", icon: <Link2 size={12} />, onClick: () => { onOpenFk(); onClose(); }, show: isFk },
|
{ label: "Open FK reference", icon: <Link2 size={12} />, onClick: () => { onOpenFk(); onClose(); }, show: isFk },
|
||||||
|
|||||||
@@ -13,6 +13,42 @@ describe("CellEditor", () => {
|
|||||||
fireEvent.keyDown(input, { key: "Enter" });
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
expect(onCommit).toHaveBeenCalledWith("Alicia");
|
expect(onCommit).toHaveBeenCalledWith("Alicia");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders a native date input for date columns", () => {
|
||||||
|
render(<CellEditor initialValue="2024-01-15" dataType="date" onCommit={vi.fn()} onCancel={vi.fn()} />);
|
||||||
|
const input = screen.getByDisplayValue("2024-01-15");
|
||||||
|
expect(input).toHaveAttribute("type", "date");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a datetime-local input for timestamp columns", () => {
|
||||||
|
render(<CellEditor initialValue="2024-01-15 10:30:00" dataType="timestamp without time zone" onCommit={vi.fn()} onCancel={vi.fn()} />);
|
||||||
|
const input = screen.getByDisplayValue("2024-01-15T10:30");
|
||||||
|
expect(input).toHaveAttribute("type", "datetime-local");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("commits a datetime-local value in canonical DB format (space separator + seconds)", () => {
|
||||||
|
const onCommit = vi.fn();
|
||||||
|
render(<CellEditor initialValue="2024-01-15 10:30:00" dataType="timestamp without time zone" onCommit={onCommit} onCancel={vi.fn()} />);
|
||||||
|
const input = screen.getByDisplayValue("2024-01-15T10:30");
|
||||||
|
fireEvent.change(input, { target: { value: "2024-01-15T11:45" } });
|
||||||
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
|
expect(onCommit).toHaveBeenCalledWith("2024-01-15 11:45:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a boolean select with true/false options", () => {
|
||||||
|
render(<CellEditor initialValue="true" dataType="boolean" onCommit={vi.fn()} onCancel={vi.fn()} />);
|
||||||
|
const select = screen.getByRole("combobox");
|
||||||
|
expect(select).toHaveValue("true");
|
||||||
|
expect(screen.getByRole("option", { name: "true" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("option", { name: "false" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a number input for integer columns", () => {
|
||||||
|
render(<CellEditor initialValue="42" dataType="integer" onCommit={vi.fn()} onCancel={vi.fn()} />);
|
||||||
|
const input = screen.getByRole("spinbutton");
|
||||||
|
expect(input).toHaveAttribute("type", "number");
|
||||||
|
expect(input).toHaveAttribute("step", "1");
|
||||||
|
});
|
||||||
it("commits null when a nullable cell is emptied", () => {
|
it("commits null when a nullable cell is emptied", () => {
|
||||||
const onCommit = vi.fn();
|
const onCommit = vi.fn();
|
||||||
render(<CellEditor initialValue="Alice" dataType="text" onCommit={onCommit} onCancel={vi.fn()} nullable />);
|
render(<CellEditor initialValue="Alice" dataType="text" onCommit={onCommit} onCancel={vi.fn()} nullable />);
|
||||||
@@ -40,7 +76,7 @@ describe("CellEditor", () => {
|
|||||||
it("blocks empty commits on non-nullable non-text columns and shows an error", () => {
|
it("blocks empty commits on non-nullable non-text columns and shows an error", () => {
|
||||||
const onCommit = vi.fn();
|
const onCommit = vi.fn();
|
||||||
render(<CellEditor initialValue="42" dataType="integer" onCommit={onCommit} onCancel={vi.fn()} />);
|
render(<CellEditor initialValue="42" dataType="integer" onCommit={onCommit} onCancel={vi.fn()} />);
|
||||||
const input = screen.getByRole("textbox");
|
const input = screen.getByRole("spinbutton");
|
||||||
fireEvent.change(input, { target: { value: "" } });
|
fireEvent.change(input, { target: { value: "" } });
|
||||||
fireEvent.keyDown(input, { key: "Enter" });
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
expect(onCommit).not.toHaveBeenCalled();
|
expect(onCommit).not.toHaveBeenCalled();
|
||||||
@@ -57,7 +93,7 @@ describe("CellEditor", () => {
|
|||||||
it("clears the validation error as soon as the user types again", () => {
|
it("clears the validation error as soon as the user types again", () => {
|
||||||
const onCommit = vi.fn();
|
const onCommit = vi.fn();
|
||||||
render(<CellEditor initialValue="42" dataType="integer" onCommit={onCommit} onCancel={vi.fn()} />);
|
render(<CellEditor initialValue="42" dataType="integer" onCommit={onCommit} onCancel={vi.fn()} />);
|
||||||
const input = screen.getByRole("textbox");
|
const input = screen.getByRole("spinbutton");
|
||||||
fireEvent.change(input, { target: { value: "" } });
|
fireEvent.change(input, { target: { value: "" } });
|
||||||
fireEvent.keyDown(input, { key: "Enter" });
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
expect(screen.getByTestId("cell-editor-error")).toBeInTheDocument();
|
expect(screen.getByTestId("cell-editor-error")).toBeInTheDocument();
|
||||||
|
|||||||
@@ -30,6 +30,45 @@ const controlClass =
|
|||||||
/** Types that tolerate an empty string when NOT NULL ('' is a valid value). */
|
/** Types that tolerate an empty string when NOT NULL ('' is a valid value). */
|
||||||
const TEXT_LIKE = ["char", "text", "uuid", "bit"];
|
const TEXT_LIKE = ["char", "text", "uuid", "bit"];
|
||||||
|
|
||||||
|
/** Map a column data type to a "smart" input kind. */
|
||||||
|
type InputKind = "date" | "datetime" | "time" | "boolean" | "number" | "text";
|
||||||
|
|
||||||
|
const NUMERIC_TYPES = new Set([
|
||||||
|
"integer", "bigint", "smallint", "int", "int2", "int4", "int8",
|
||||||
|
"tinyint", "mediumint", "numeric", "decimal", "real", "double",
|
||||||
|
"double precision", "float", "float4", "float8", "money",
|
||||||
|
"serial", "bigserial", "smallserial",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const INTEGER_TYPES = new Set([
|
||||||
|
"integer", "bigint", "smallint", "int", "int2", "int4", "int8",
|
||||||
|
"tinyint", "mediumint", "serial", "bigserial", "smallserial",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function inputKindForType(dataType: string): InputKind {
|
||||||
|
const t = dataType.toLowerCase().trim();
|
||||||
|
if (t === "date") return "date";
|
||||||
|
if (t.includes("timestamp") || t === "datetime") return "datetime";
|
||||||
|
if (t === "time" || t === "timetz" || t.startsWith("time ")) return "time";
|
||||||
|
if (t === "boolean" || t === "bool") return "boolean";
|
||||||
|
if (NUMERIC_TYPES.has(t)) return "number";
|
||||||
|
return "text";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert a DB timestamp ("2024-01-15 10:30:00") to datetime-local ("2024-01-15T10:30"). */
|
||||||
|
function toDatetimeLocal(v: string): string {
|
||||||
|
const m = v.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})/);
|
||||||
|
return m ? `${m[1]}T${m[2]}` : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert a datetime-local value ("2024-01-15T10:30") to the canonical DB
|
||||||
|
* format ("2024-01-15 10:30:00") accepted by both PostgreSQL and MySQL. */
|
||||||
|
function fromDatetimeLocal(v: string): string {
|
||||||
|
const m = v.match(/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})(?::(\d{2}))?/);
|
||||||
|
if (m) return `${m[1]} ${m[2]}:${m[3] ?? "00"}`;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
export function CellEditor({
|
export function CellEditor({
|
||||||
initialValue,
|
initialValue,
|
||||||
dataType,
|
dataType,
|
||||||
@@ -40,7 +79,11 @@ export function CellEditor({
|
|||||||
onCommit,
|
onCommit,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: CellEditorProps) {
|
}: CellEditorProps) {
|
||||||
const [value, setValue] = useState(initialValue);
|
const kind = inputKindForType(dataType);
|
||||||
|
const isInteger = INTEGER_TYPES.has(dataType.toLowerCase().trim());
|
||||||
|
const [value, setValue] = useState(
|
||||||
|
kind === "datetime" ? toDatetimeLocal(initialValue) : initialValue,
|
||||||
|
);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -73,6 +116,8 @@ export function CellEditor({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (enumValues && enumValues.length > 0) {
|
if (enumValues && enumValues.length > 0) {
|
||||||
enumRef.current?.focus();
|
enumRef.current?.focus();
|
||||||
|
} else if (kind === "boolean") {
|
||||||
|
enumRef.current?.focus();
|
||||||
} else if (fkOptions && fkOptions.length > 0) {
|
} else if (fkOptions && fkOptions.length > 0) {
|
||||||
searchRef.current?.focus();
|
searchRef.current?.focus();
|
||||||
searchRef.current?.select();
|
searchRef.current?.select();
|
||||||
@@ -258,6 +303,63 @@ export function CellEditor({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (kind === "boolean") {
|
||||||
|
return (
|
||||||
|
<div ref={rootRef} className={`flex h-full w-full items-center gap-1.5 px-1.5 ${error ? "ring-1 ring-inset ring-red-500/60" : ""}`}>
|
||||||
|
<select
|
||||||
|
ref={enumRef}
|
||||||
|
className={controlClass}
|
||||||
|
value={initialValue}
|
||||||
|
onChange={(e) => commitRaw(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">{nullable ? "NULL" : "—"}</option>
|
||||||
|
<option value="true">true</option>
|
||||||
|
<option value="false">false</option>
|
||||||
|
</select>
|
||||||
|
{errorBubble}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === "date" || kind === "datetime" || kind === "time" || kind === "number") {
|
||||||
|
const inputType = kind === "datetime" ? "datetime-local" : kind;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={rootRef}
|
||||||
|
data-testid="cell-editor"
|
||||||
|
className={`flex h-full w-full items-center gap-2 ${error ? "ring-1 ring-inset ring-red-500/60" : ""}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={ref as any}
|
||||||
|
type={inputType}
|
||||||
|
className={inputClass}
|
||||||
|
value={value}
|
||||||
|
step={kind === "number" ? (isInteger ? "1" : "any") : undefined}
|
||||||
|
onChange={(e) => {
|
||||||
|
setValue(e.target.value);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
commitRaw(kind === "datetime" ? fromDatetimeLocal(value) : value);
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{errorBubble}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const cls = large
|
const cls = large
|
||||||
? `${inputClass} h-4 resize-none overflow-y-auto whitespace-pre leading-none py-0.5`
|
? `${inputClass} h-4 resize-none overflow-y-auto whitespace-pre leading-none py-0.5`
|
||||||
: inputClass;
|
: inputClass;
|
||||||
|
|||||||
@@ -48,6 +48,21 @@ describe("VirtualDataGrid", () => {
|
|||||||
expect(screen.getByRole("textbox")).toBeInTheDocument();
|
expect(screen.getByRole("textbox")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders a pending insert row with an 'auto' placeholder and no selection checkbox", () => {
|
||||||
|
const rows = [[null, null], ...mockRows];
|
||||||
|
mockGetTotalSize.mockReturnValue(rows.length * 36);
|
||||||
|
mockGetVirtualItems.mockReturnValue(rows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
|
||||||
|
render(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={rows} columns={mockColumns}
|
||||||
|
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
|
||||||
|
dbType="postgresql" tabType="table" pendingInsertChangeIds={["ch-1"]} />);
|
||||||
|
// Non-editable PK column in the insert row shows "auto" instead of NULL.
|
||||||
|
expect(screen.getByText("auto")).toBeInTheDocument();
|
||||||
|
// The insert row shows a "+" marker instead of a selection checkbox.
|
||||||
|
expect(screen.getByTitle("New row")).toBeInTheDocument();
|
||||||
|
// Real rows still render their checkboxes (2 real rows).
|
||||||
|
expect(screen.getAllByRole("checkbox")).toHaveLength(3); // 1 select-all + 2 row checkboxes
|
||||||
|
});
|
||||||
|
|
||||||
it("shows staged values passed from the parent + a pending outline", () => {
|
it("shows staged values passed from the parent + a pending outline", () => {
|
||||||
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
|
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
|
||||||
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
|
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ interface VirtualDataGridProps {
|
|||||||
stagedValues?: Record<string, string | null>;
|
stagedValues?: Record<string, string | null>;
|
||||||
/** Keys of cells with a PENDING (not yet committed) update → drives the pulsing orange outline. */
|
/** Keys of cells with a PENDING (not yet committed) update → drives the pulsing orange outline. */
|
||||||
pendingKeys?: Record<string, boolean>;
|
pendingKeys?: Record<string, boolean>;
|
||||||
|
/** Ordered change ids of pending insert rows prepended to `rows`. */
|
||||||
|
pendingInsertChangeIds?: string[];
|
||||||
|
/** Stage a cell edit on a pending insert row (updates the insert's newData). */
|
||||||
|
onStageInsertCell?: (changeId: string, column: string, value: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROW_HEIGHT = 36;
|
const ROW_HEIGHT = 36;
|
||||||
@@ -74,20 +78,24 @@ export function VirtualDataGrid({
|
|||||||
fkPlaceholders,
|
fkPlaceholders,
|
||||||
stagedValues,
|
stagedValues,
|
||||||
pendingKeys,
|
pendingKeys,
|
||||||
|
pendingInsertChangeIds,
|
||||||
|
onStageInsertCell,
|
||||||
}: VirtualDataGridProps) {
|
}: VirtualDataGridProps) {
|
||||||
const parentRef = useRef<HTMLDivElement>(null);
|
const parentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const visibleColumns = columns.filter((c) => !hiddenColumns.has(c.name));
|
const visibleColumns = columns.filter((c) => !hiddenColumns.has(c.name));
|
||||||
const hasColumns = columns.length > 0;
|
const hasColumns = columns.length > 0;
|
||||||
const allSelected = rows.length > 0 && selectedRows.size === rows.length;
|
const pendingInsertCount = pendingInsertChangeIds?.length ?? 0;
|
||||||
|
const realRowCount = rows.length - pendingInsertCount;
|
||||||
|
const allSelected = realRowCount > 0 && selectedRows.size === realRowCount;
|
||||||
const selectAllRef = useRef<HTMLInputElement>(null);
|
const selectAllRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Indeterminate state for partial selection
|
// Indeterminate state for partial selection
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectAllRef.current) {
|
if (selectAllRef.current) {
|
||||||
selectAllRef.current.indeterminate = selectedRows.size > 0 && selectedRows.size < rows.length;
|
selectAllRef.current.indeterminate = selectedRows.size > 0 && selectedRows.size < realRowCount;
|
||||||
}
|
}
|
||||||
}, [selectedRows.size, rows.length]);
|
}, [selectedRows.size, realRowCount]);
|
||||||
|
|
||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useVirtualizer({
|
||||||
count: rows.length,
|
count: rows.length,
|
||||||
@@ -282,6 +290,8 @@ export function VirtualDataGrid({
|
|||||||
const isJson = !isNull && (col.data_type === "jsonb" || col.data_type === "json");
|
const isJson = !isNull && (col.data_type === "jsonb" || col.data_type === "json");
|
||||||
const jp = isJson ? jsonPreview(displayCell) : { label: "", isJson: false };
|
const jp = isJson ? jsonPreview(displayCell) : { label: "", isJson: false };
|
||||||
const editable = isCellEditable(col, tabType, dbType, readOnly);
|
const editable = isCellEditable(col, tabType, dbType, readOnly);
|
||||||
|
const isInsertRow = rowIndex < pendingInsertCount;
|
||||||
|
const insertChangeId = isInsertRow ? pendingInsertChangeIds?.[rowIndex] : undefined;
|
||||||
const isActive = activeCell?.row === rowIndex && activeCell?.col === colIndex;
|
const isActive = activeCell?.row === rowIndex && activeCell?.col === colIndex;
|
||||||
const isEditing = editingCell?.row === rowIndex && editingCell?.col === colIndex;
|
const isEditing = editingCell?.row === rowIndex && editingCell?.col === colIndex;
|
||||||
const isPending =
|
const isPending =
|
||||||
@@ -307,23 +317,27 @@ export function VirtualDataGrid({
|
|||||||
// revert/display stays correct even after repeated edits of the same cell.
|
// revert/display stays correct even after repeated edits of the same cell.
|
||||||
const dbValue = ci >= 0 ? row[ci] : undefined;
|
const dbValue = ci >= 0 ? row[ci] : undefined;
|
||||||
if (committed !== (dbValue === null || dbValue === undefined ? null : dbValue)) {
|
if (committed !== (dbValue === null || dbValue === undefined ? null : dbValue)) {
|
||||||
const locator = getLocator?.(row) ?? {};
|
if (isInsertRow && insertChangeId) {
|
||||||
onStageEdit?.(
|
onStageInsertCell?.(insertChangeId, col.name, committed);
|
||||||
cellToUpdateChange({
|
} else {
|
||||||
schema,
|
const locator = getLocator?.(row) ?? {};
|
||||||
table,
|
onStageEdit?.(
|
||||||
primaryKey: locator,
|
cellToUpdateChange({
|
||||||
oldData: { [col.name]: dbValue },
|
schema,
|
||||||
newData: { [col.name]: committed },
|
table,
|
||||||
}) as {
|
primaryKey: locator,
|
||||||
type: "update";
|
oldData: { [col.name]: dbValue },
|
||||||
schema: string;
|
newData: { [col.name]: committed },
|
||||||
table: string;
|
}) as {
|
||||||
primaryKey: Record<string, unknown>;
|
type: "update";
|
||||||
oldData: Record<string, unknown>;
|
schema: string;
|
||||||
newData: Record<string, unknown>;
|
table: string;
|
||||||
},
|
primaryKey: Record<string, unknown>;
|
||||||
);
|
oldData: Record<string, unknown>;
|
||||||
|
newData: Record<string, unknown>;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setPendingCellKey(cellKey);
|
setPendingCellKey(cellKey);
|
||||||
setEditingCell(null);
|
setEditingCell(null);
|
||||||
@@ -355,13 +369,15 @@ export function VirtualDataGrid({
|
|||||||
}
|
}
|
||||||
style={{ width: getWidth(col.name), flexShrink: 0 }}
|
style={{ width: getWidth(col.name), flexShrink: 0 }}
|
||||||
title={
|
title={
|
||||||
isNull
|
isInsertRow && !editable
|
||||||
? "NULL"
|
? "auto (default)"
|
||||||
: isFk
|
: isNull
|
||||||
? `FK → ${col.fk_ref![0]}.${col.fk_ref![1]}: ${String(displayCell)}`
|
? "NULL"
|
||||||
: isJson
|
: isFk
|
||||||
? "Click to view JSON"
|
? `FK → ${col.fk_ref![0]}.${col.fk_ref![1]}: ${String(displayCell)}`
|
||||||
: String(displayCell)
|
: isJson
|
||||||
|
? "Click to view JSON"
|
||||||
|
: String(displayCell)
|
||||||
}
|
}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
setActiveCell({ row: rowIndex, col: colIndex });
|
setActiveCell({ row: rowIndex, col: colIndex });
|
||||||
@@ -390,6 +406,8 @@ export function VirtualDataGrid({
|
|||||||
onCancel={() => setEditingCell(null)}
|
onCancel={() => setEditingCell(null)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
) : isInsertRow && !editable ? (
|
||||||
|
<span className="italic text-text-muted/60">auto</span>
|
||||||
) : isNull ? (
|
) : isNull ? (
|
||||||
<span className="italic text-text-muted">NULL</span>
|
<span className="italic text-text-muted">NULL</span>
|
||||||
) : isJson ? (
|
) : isJson ? (
|
||||||
@@ -420,7 +438,7 @@ export function VirtualDataGrid({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[activeCell, columns, dbType, editingCell, enumValues, fkOptions, fkPlaceholders, getLocator, handleFkClick, onStageEdit, schema, table, tabType, getWidth, pendingCell, stagedValues, pendingKeys, pendingCellKey],
|
[activeCell, columns, dbType, editingCell, enumValues, fkOptions, fkPlaceholders, getLocator, handleFkClick, onStageEdit, onStageInsertCell, schema, table, tabType, getWidth, pendingCell, stagedValues, pendingKeys, pendingCellKey, pendingInsertCount, pendingInsertChangeIds],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── context menu helpers ──────────────────────────────
|
// ── context menu helpers ──────────────────────────────
|
||||||
@@ -446,6 +464,11 @@ export function VirtualDataGrid({
|
|||||||
const ci = columns.findIndex((c) => c.name === column.name);
|
const ci = columns.findIndex((c) => c.name === column.name);
|
||||||
const value = rows[row]?.[ci];
|
const value = rows[row]?.[ci];
|
||||||
if (value === null || value === undefined) return;
|
if (value === null || value === undefined) return;
|
||||||
|
if (row < pendingInsertCount) {
|
||||||
|
const changeId = pendingInsertChangeIds?.[row];
|
||||||
|
if (changeId) onStageInsertCell?.(changeId, column.name, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const locator = getLocator?.(rows[row]) ?? {};
|
const locator = getLocator?.(rows[row]) ?? {};
|
||||||
onStageEdit?.(
|
onStageEdit?.(
|
||||||
cellToUpdateChange({
|
cellToUpdateChange({
|
||||||
@@ -464,7 +487,7 @@ export function VirtualDataGrid({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[columns, dbType, getLocator, onStageEdit, readOnly, rows, schema, table, tabType, visibleColumns],
|
[columns, dbType, getLocator, onStageEdit, onStageInsertCell, readOnly, rows, schema, table, tabType, visibleColumns, pendingInsertCount, pendingInsertChangeIds],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -528,15 +551,18 @@ export function VirtualDataGrid({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||||
const row = rows[virtualRow.index];
|
const gridIndex = virtualRow.index;
|
||||||
const isSelected = selectedRows.has(virtualRow.index);
|
const isInsertRow = gridIndex < pendingInsertCount;
|
||||||
|
const realIndex = isInsertRow ? -1 : gridIndex - pendingInsertCount;
|
||||||
|
const row = rows[gridIndex];
|
||||||
|
const isSelected = !isInsertRow && selectedRows.has(realIndex);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={virtualRow.key}
|
key={virtualRow.key}
|
||||||
data-index={virtualRow.index}
|
data-index={gridIndex}
|
||||||
className={`flex items-center border-b border-border ${
|
className={`flex items-center border-b border-border ${
|
||||||
isSelected ? "bg-accent/5" : ""
|
isSelected ? "bg-accent/5" : ""
|
||||||
} hover:bg-surface/50`}
|
} ${isInsertRow ? "bg-accent/[0.03] ring-1 ring-inset ring-accent/60" : ""} hover:bg-surface/50`}
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: 0,
|
top: 0,
|
||||||
@@ -546,20 +572,28 @@ export function VirtualDataGrid({
|
|||||||
transform: `translateY(${virtualRow.start}px)`,
|
transform: `translateY(${virtualRow.start}px)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{hasColumns && (
|
{hasColumns &&
|
||||||
<div
|
(isInsertRow ? (
|
||||||
style={{ width: 40, minWidth: 40 }}
|
<div
|
||||||
className="flex items-center justify-center border-r border-border self-stretch"
|
style={{ width: 40, minWidth: 40 }}
|
||||||
>
|
className="flex items-center justify-center border-r border-border self-stretch"
|
||||||
<input
|
>
|
||||||
type="checkbox"
|
<span className="text-[10px] text-accent/70" title="New row">+</span>
|
||||||
checked={isSelected}
|
</div>
|
||||||
onChange={() => onToggleRow(virtualRow.index)}
|
) : (
|
||||||
className="w-3.5 h-3.5 rounded border-border cursor-pointer accent-accent"
|
<div
|
||||||
/>
|
style={{ width: 40, minWidth: 40 }}
|
||||||
</div>
|
className="flex items-center justify-center border-r border-border self-stretch"
|
||||||
)}
|
>
|
||||||
{visibleColumns.map((col, i) => renderCell(col, row, virtualRow.index, i))}
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => onToggleRow(realIndex)}
|
||||||
|
className="w-3.5 h-3.5 rounded border-border cursor-pointer accent-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{visibleColumns.map((col, i) => renderCell(col, row, gridIndex, i))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -601,6 +635,7 @@ export function VirtualDataGrid({
|
|||||||
isJson={ctxCol.data_type === "jsonb" || ctxCol.data_type === "json"}
|
isJson={ctxCol.data_type === "jsonb" || ctxCol.data_type === "json"}
|
||||||
isFk={ctxCol.is_fk && ctxCol.fk_ref != null}
|
isFk={ctxCol.is_fk && ctxCol.fk_ref != null}
|
||||||
nullable={ctxCol.is_nullable}
|
nullable={ctxCol.is_nullable}
|
||||||
|
isInsertRow={ctxMenu.row < pendingInsertCount}
|
||||||
onCopy={() => {
|
onCopy={() => {
|
||||||
void copyCellValue(ctxMenu.row, ctxMenu.col);
|
void copyCellValue(ctxMenu.row, ctxMenu.col);
|
||||||
setCtxMenu(null);
|
setCtxMenu(null);
|
||||||
@@ -610,11 +645,11 @@ export function VirtualDataGrid({
|
|||||||
setCtxMenu(null);
|
setCtxMenu(null);
|
||||||
}}
|
}}
|
||||||
onViewRow={() => {
|
onViewRow={() => {
|
||||||
onOpenRowDetail?.(ctxMenu.row);
|
onOpenRowDetail?.(ctxMenu.row - pendingInsertCount);
|
||||||
setCtxMenu(null);
|
setCtxMenu(null);
|
||||||
}}
|
}}
|
||||||
onSelectRow={() => {
|
onSelectRow={() => {
|
||||||
onToggleRow(ctxMenu.row);
|
onToggleRow(ctxMenu.row - pendingInsertCount);
|
||||||
setCtxMenu(null);
|
setCtxMenu(null);
|
||||||
}}
|
}}
|
||||||
onEdit={() => {
|
onEdit={() => {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import tauriConf from "../../src-tauri/tauri.conf.json";
|
import tauriConf from "../../src-tauri/tauri.conf.json";
|
||||||
|
|
||||||
describe("tauri bundle config (v0.7.12)", () => {
|
describe("tauri bundle config (v0.7.15)", () => {
|
||||||
it("declares bundled pg_tools resources", () => {
|
it("declares bundled pg_tools resources", () => {
|
||||||
expect(tauriConf.bundle.resources).toContain("resources/pg_tools/*");
|
expect(tauriConf.bundle.resources).toContain("resources/pg_tools/*");
|
||||||
});
|
});
|
||||||
it("version is 0.7.12", () => {
|
it("version is 0.7.15", () => {
|
||||||
expect(tauriConf.version).toBe("0.7.12");
|
expect(tauriConf.version).toBe("0.7.15");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -45,6 +45,10 @@ describe("buildChangeSql", () => {
|
|||||||
const item = { id: "1", type: "insert", schema: "public", table: "t", newData: { a: 1, b: "x" } } as any;
|
const item = { id: "1", type: "insert", schema: "public", table: "t", newData: { a: 1, b: "x" } } as any;
|
||||||
expect(buildChangeSql(item)).toBe('INSERT INTO "public"."t" ("a", "b") VALUES (1, \'x\')');
|
expect(buildChangeSql(item)).toBe('INSERT INTO "public"."t" ("a", "b") VALUES (1, \'x\')');
|
||||||
});
|
});
|
||||||
|
it("insert with no columns falls back to DEFAULT VALUES", () => {
|
||||||
|
const item = { id: "1", type: "insert", schema: "public", table: "t", newData: {} } as any;
|
||||||
|
expect(buildChangeSql(item)).toBe('INSERT INTO "public"."t" DEFAULT VALUES');
|
||||||
|
});
|
||||||
it("update", () => {
|
it("update", () => {
|
||||||
const item = { id: "1", type: "update", schema: "public", table: "t", primaryKey: { id: 5 }, newData: { name: "O'Brien" } } as any;
|
const item = { id: "1", type: "update", schema: "public", table: "t", primaryKey: { id: 5 }, newData: { name: "O'Brien" } } as any;
|
||||||
expect(buildChangeSql(item)).toBe('UPDATE "public"."t" SET "name" = \'O\'\'Brien\' WHERE "id" = 5');
|
expect(buildChangeSql(item)).toBe('UPDATE "public"."t" SET "name" = \'O\'\'Brien\' WHERE "id" = 5');
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export function buildChangeSql(item: QueueItem): string {
|
|||||||
case "insert": {
|
case "insert": {
|
||||||
const data = item.newData ?? {};
|
const data = item.newData ?? {};
|
||||||
const cols = Object.keys(data);
|
const cols = Object.keys(data);
|
||||||
|
if (cols.length === 0) return `INSERT INTO ${t} DEFAULT VALUES`;
|
||||||
return `INSERT INTO ${t} (${cols.map(q).join(", ")}) VALUES (${cols.map((c) => lit(data[c])).join(", ")})`;
|
return `INSERT INTO ${t} (${cols.map(q).join(", ")}) VALUES (${cols.map((c) => lit(data[c])).join(", ")})`;
|
||||||
}
|
}
|
||||||
case "update": {
|
case "update": {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { describe, it, expect } from "vitest";
|
|||||||
import agents from "../../../AGENTS.md?raw";
|
import agents from "../../../AGENTS.md?raw";
|
||||||
import readme from "../../../README.md?raw";
|
import readme from "../../../README.md?raw";
|
||||||
|
|
||||||
describe("v0.7.12 docs coverage", () => {
|
describe("v0.7.15 docs coverage", () => {
|
||||||
it("AGENTS.md marks inline cell editing complete", () => {
|
it("AGENTS.md marks inline cell editing complete", () => {
|
||||||
expect(agents).toContain("Inline cell editing");
|
expect(agents).toContain("Inline cell editing");
|
||||||
expect(agents).toMatch(/Inline cell editing \| ✅/);
|
expect(agents).toMatch(/Inline cell editing \| ✅/);
|
||||||
@@ -24,8 +24,8 @@ describe("v0.7.12 docs coverage", () => {
|
|||||||
expect(agents).toMatch(/Connection status indicator on cards \| ✅/);
|
expect(agents).toMatch(/Connection status indicator on cards \| ✅/);
|
||||||
expect(agents).toMatch(/Move-to-folder bulk action \| ✅/);
|
expect(agents).toMatch(/Move-to-folder bulk action \| ✅/);
|
||||||
});
|
});
|
||||||
it("README declares v0.7.12", () => {
|
it("README declares v0.7.15", () => {
|
||||||
expect(readme).toContain("0.7.12");
|
expect(readme).toContain("0.7.15");
|
||||||
});
|
});
|
||||||
it("AGENTS.md marks schema CRUD complete", () => {
|
it("AGENTS.md marks schema CRUD complete", () => {
|
||||||
expect(agents).toMatch(/Schema CRUD \| ✅/);
|
expect(agents).toMatch(/Schema CRUD \| ✅/);
|
||||||
@@ -64,9 +64,9 @@ describe("v0.7.12 docs coverage", () => {
|
|||||||
expect(agents).toMatch(/Cancel long-running queries \| ✅/);
|
expect(agents).toMatch(/Cancel long-running queries \| ✅/);
|
||||||
expect(agents).toMatch(/Settings export\/import \| ✅/);
|
expect(agents).toMatch(/Settings export\/import \| ✅/);
|
||||||
});
|
});
|
||||||
it("README links to v0.7.12 assets in both download tables", () => {
|
it("README links to v0.7.15 assets in both download tables", () => {
|
||||||
expect(readme).toContain("releases/download/v0.7.12/");
|
expect(readme).toContain("releases/download/v0.7.15/");
|
||||||
expect(readme).toContain("Gridline_0.7.12_aarch64.dmg");
|
expect(readme).toContain("Gridline_0.7.15_aarch64.dmg");
|
||||||
expect(readme).toContain("Gridline-0.7.12-1.x86_64.rpm");
|
expect(readme).toContain("Gridline-0.7.15-1.x86_64.rpm");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
|
|||||||
import pkg from "../../package.json";
|
import pkg from "../../package.json";
|
||||||
|
|
||||||
describe("version", () => {
|
describe("version", () => {
|
||||||
it("declares v0.7.12 across the app shell", () => {
|
it("declares v0.7.15 across the app shell", () => {
|
||||||
expect(pkg.version).toBe("0.7.12");
|
expect(pkg.version).toBe("0.7.15");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -247,6 +247,16 @@ describe("dbViewerStore", () => {
|
|||||||
expect(useDbViewerStore.getState().changesQueue).toHaveLength(0);
|
expect(useDbViewerStore.getState().changesQueue).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("updateChange patches a single change in place", () => {
|
||||||
|
useDbViewerStore.getState().addChange({ type: "insert", schema: "public", table: "t", newData: { a: null }, description: "x" } as any);
|
||||||
|
const id = useDbViewerStore.getState().changesQueue[0].id;
|
||||||
|
useDbViewerStore.getState().updateChange(id, { newData: { a: "hello" } });
|
||||||
|
const item = useDbViewerStore.getState().changesQueue[0];
|
||||||
|
expect(item.newData).toEqual({ a: "hello" });
|
||||||
|
expect(item.type).toBe("insert");
|
||||||
|
expect(item.description).toBe("x");
|
||||||
|
});
|
||||||
|
|
||||||
it("clearChanges empties the queue", () => {
|
it("clearChanges empties the queue", () => {
|
||||||
useDbViewerStore.getState().addChange({ type: "insert", schema: "public", table: "t", newData: { a: 1 }, description: "x" } as any);
|
useDbViewerStore.getState().addChange({ type: "insert", schema: "public", table: "t", newData: { a: 1 }, description: "x" } as any);
|
||||||
useDbViewerStore.getState().addChange({ type: "delete", schema: "public", table: "t", primaryKey: { id: 1 }, description: "y" } as any);
|
useDbViewerStore.getState().addChange({ type: "delete", schema: "public", table: "t", primaryKey: { id: 1 }, description: "y" } as any);
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ interface DbViewerState {
|
|||||||
}) => void;
|
}) => void;
|
||||||
cancelChange: (changeId: string) => void;
|
cancelChange: (changeId: string) => void;
|
||||||
removeChange: (changeId: string) => void;
|
removeChange: (changeId: string) => void;
|
||||||
|
updateChange: (changeId: string, patch: Partial<QueueItem>) => void;
|
||||||
clearChanges: () => void;
|
clearChanges: () => void;
|
||||||
markChangeCommitted: (changeId: string) => void;
|
markChangeCommitted: (changeId: string) => void;
|
||||||
markChangeFailed: (changeId: string, error: string) => void;
|
markChangeFailed: (changeId: string, error: string) => void;
|
||||||
@@ -547,6 +548,13 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
|
|||||||
changesQueue: state.changesQueue.filter((c) => c.id !== changeId),
|
changesQueue: state.changesQueue.filter((c) => c.id !== changeId),
|
||||||
})),
|
})),
|
||||||
|
|
||||||
|
updateChange: (changeId, patch) =>
|
||||||
|
set((state) => ({
|
||||||
|
changesQueue: state.changesQueue.map((c) =>
|
||||||
|
c.id === changeId ? { ...c, ...patch } : c,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
|
||||||
clearChanges: () => set({ changesQueue: [] }),
|
clearChanges: () => set({ changesQueue: [] }),
|
||||||
|
|
||||||
markChangeCommitted: (changeId) =>
|
markChangeCommitted: (changeId) =>
|
||||||
|
|||||||
@@ -11,13 +11,8 @@
|
|||||||
#
|
#
|
||||||
# brew install --cask gridline
|
# brew install --cask gridline
|
||||||
#
|
#
|
||||||
# QUARANTINE: Gridline is ad-hoc signed (not Developer-ID signed/notarized),
|
# SIGNING: Gridline is Developer-ID signed + notarized, so macOS trusts it
|
||||||
# so Homebrew casks (which deliberately set com.apple.quarantine on the
|
# and no quarantine-stripping postflight is needed.
|
||||||
# downloaded artifact) would make Gatekeeper refuse to launch it. The
|
|
||||||
# `postflight` block strips the quarantine flag automatically — the standard
|
|
||||||
# GoReleaser-documented pattern for unsigned casks — so `brew install` works
|
|
||||||
# with no manual `xattr` step. This is a Gatekeeper bypass; drop the
|
|
||||||
# postflight once the app is properly signed + notarized.
|
|
||||||
|
|
||||||
cask "gridline" do
|
cask "gridline" do
|
||||||
arch arm: "aarch64", intel: "x64"
|
arch arm: "aarch64", intel: "x64"
|
||||||
@@ -26,24 +21,15 @@ cask "gridline" do
|
|||||||
sha256 arm: "@ARM_SHA256@",
|
sha256 arm: "@ARM_SHA256@",
|
||||||
intel: "@INTEL_SHA256@"
|
intel: "@INTEL_SHA256@"
|
||||||
|
|
||||||
url "https://github.com/AdrianBonpin/gridline/releases/download/v#{version}/Gridline_#{version}_#{arch}.dmg"
|
url "https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v#{version}/Gridline_#{version}_#{arch}.dmg"
|
||||||
name "Gridline"
|
name "Gridline"
|
||||||
desc "Open-source, cross-platform database GUI client for PostgreSQL"
|
desc "Open-source, cross-platform database GUI client for PostgreSQL"
|
||||||
homepage "https://github.com/AdrianBonpin/gridline"
|
homepage "https://git.ranio.xyz/adrianbonpin/gridline"
|
||||||
|
|
||||||
depends_on macos: ">= :monterey"
|
depends_on macos: :monterey
|
||||||
|
|
||||||
app "Gridline.app"
|
app "Gridline.app"
|
||||||
|
|
||||||
# The "installation step" — strip the quarantine flag so the unsigned
|
|
||||||
# (ad-hoc signed) app launches without the Gatekeeper prompt.
|
|
||||||
postflight do
|
|
||||||
if OS.mac?
|
|
||||||
system_command "/usr/bin/xattr",
|
|
||||||
args: ["-dr", "com.apple.quarantine", "#{appdir}/Gridline.app"]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
zap trash: [
|
zap trash: [
|
||||||
"~/Library/Application Support/com.adrianbonpin.gridline",
|
"~/Library/Application Support/com.adrianbonpin.gridline",
|
||||||
"~/Library/Caches/com.adrianbonpin.gridline",
|
"~/Library/Caches/com.adrianbonpin.gridline",
|
||||||
|
|||||||
+96
-3
@@ -45,12 +45,76 @@ get_token() {
|
|||||||
if [ -n "$tok" ]; then echo "$tok"; return; fi
|
if [ -n "$tok" ]; then echo "$tok"; return; fi
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
read -rsp "Gitea personal access token (repo write): " GITEA_TOKEN
|
# Fall back to the git credential store: tea's OAuth login stores an access
|
||||||
echo "$GITEA_TOKEN"
|
# token as the password for every push to this remote, and Gitea accepts it
|
||||||
|
# for API auth (Authorization: token <access_token>).
|
||||||
|
local host cred
|
||||||
|
host="${GITEA_SERVER#*://}"
|
||||||
|
cred="$(printf 'protocol=https\nhost=%s\n\n' "$host" \
|
||||||
|
| git credential fill 2>/dev/null | sed -n 's/^password=//p' | head -1 || true)"
|
||||||
|
if [ -n "$cred" ]; then echo "$cred"; return; fi
|
||||||
|
# Fail fast — continuing without a token fails only at the upload step,
|
||||||
|
# after the whole build has run.
|
||||||
|
echo "error: no Gitea token found" >&2
|
||||||
|
echo " set GITEA_TOKEN, or run 'tea login' / 'git push' once so the" >&2
|
||||||
|
echo " credential store has an entry for $host" >&2
|
||||||
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
GITEA_TOKEN="$(get_token)"
|
GITEA_TOKEN="$(get_token)"
|
||||||
|
|
||||||
|
# ---- macOS signing + notarization -----------------------------------------
|
||||||
|
# Requires a "Developer ID Application" certificate in the login keychain and
|
||||||
|
# an App Store Connect API key for notarization. See the README's "macOS
|
||||||
|
# signing" section for how to create both.
|
||||||
|
#
|
||||||
|
# Notarization credentials are read from ~/.config/gridline/notarize.env
|
||||||
|
# (never committed). Create it with:
|
||||||
|
# APPLE_API_KEY=<Key ID>
|
||||||
|
# APPLE_API_ISSUER=<Issuer ID>
|
||||||
|
# APPLE_API_KEY_PATH=/absolute/path/to/AuthKey_<KeyID>.p8
|
||||||
|
# (or drop the .p8 at ~/.appstoreconnect/private_keys/AuthKey_<KeyID>.p8 and
|
||||||
|
# omit APPLE_API_KEY_PATH — tauri auto-searches that location.)
|
||||||
|
|
||||||
|
# 1. Resolve the Developer ID Application signing identity from the keychain.
|
||||||
|
SIGNING_IDENTITY="$(security find-identity -v -p codesigning 2>/dev/null \
|
||||||
|
| grep -i 'Developer ID Application' \
|
||||||
|
| sed -E 's/.*"([^"]+)".*/\1/' \
|
||||||
|
| head -1 || true)"
|
||||||
|
if [ -z "$SIGNING_IDENTITY" ]; then
|
||||||
|
echo "ERROR: No 'Developer ID Application' certificate found in the login keychain." >&2
|
||||||
|
echo " Create one at https://developer.apple.com/account/resources/certificates/list" >&2
|
||||||
|
echo " (Certificates, IDs & Profiles -> + -> Developer ID Application), download the" >&2
|
||||||
|
echo " .cer, and double-click it to install into your login keychain." >&2
|
||||||
|
echo " Verify with: security find-identity -v -p codesigning" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
export APPLE_SIGNING_IDENTITY="$SIGNING_IDENTITY"
|
||||||
|
echo ">> Signing identity: $SIGNING_IDENTITY"
|
||||||
|
|
||||||
|
# 2. Load notarization credentials.
|
||||||
|
NOTARIZE_ENV="${HOME}/.config/gridline/notarize.env"
|
||||||
|
if [ -f "$NOTARIZE_ENV" ]; then
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$NOTARIZE_ENV"
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
if [ -z "${APPLE_API_KEY:-}" ] || [ -z "${APPLE_API_ISSUER:-}" ]; then
|
||||||
|
if [ "${GRIDLINE_SKIP_NOTARIZE:-0}" = "1" ]; then
|
||||||
|
echo ">> WARNING: notarization credentials missing; GRIDLINE_SKIP_NOTARIZE=1 set." >&2
|
||||||
|
echo " Building signed but NOT notarized (test-only — users will still see a prompt)." >&2
|
||||||
|
else
|
||||||
|
echo "ERROR: Notarization credentials not configured." >&2
|
||||||
|
echo " Create ${NOTARIZE_ENV} with APPLE_API_KEY and APPLE_API_ISSUER" >&2
|
||||||
|
echo " (and APPLE_API_KEY_PATH, or place the .p8 at ~/.appstoreconnect/private_keys/)." >&2
|
||||||
|
echo " See the README's 'macOS signing' section for the App Store Connect API key setup." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo ">> Notarization: API key ${APPLE_API_KEY} (issuer ${APPLE_API_ISSUER})"
|
||||||
|
fi
|
||||||
|
|
||||||
# ---- build bundled DB tools (macOS) ----------------------------------------
|
# ---- build bundled DB tools (macOS) ----------------------------------------
|
||||||
WORKSPACE="$(cd "$(dirname "$0")/.." && pwd)"
|
WORKSPACE="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
OUT_PG="${WORKSPACE}/desktop/src-tauri/resources/pg_tools"
|
OUT_PG="${WORKSPACE}/desktop/src-tauri/resources/pg_tools"
|
||||||
@@ -60,6 +124,8 @@ echo ">> Building PostgreSQL client tools..."
|
|||||||
mkdir -p "$OUT_PG"
|
mkdir -p "$OUT_PG"
|
||||||
PG_VER="16.4"
|
PG_VER="16.4"
|
||||||
curl -fsSL "https://ftp.postgresql.org/pub/source/v${PG_VER}/postgresql-${PG_VER}.tar.bz2" -o /tmp/pg.tar.bz2
|
curl -fsSL "https://ftp.postgresql.org/pub/source/v${PG_VER}/postgresql-${PG_VER}.tar.bz2" -o /tmp/pg.tar.bz2
|
||||||
|
# Idempotent re-runs: clear stale build dirs left by previous invocations.
|
||||||
|
rm -rf "/tmp/postgresql-${PG_VER}" /tmp/pgbuild
|
||||||
tar -xf /tmp/pg.tar.bz2 -C /tmp
|
tar -xf /tmp/pg.tar.bz2 -C /tmp
|
||||||
cd /tmp/postgresql-${PG_VER}
|
cd /tmp/postgresql-${PG_VER}
|
||||||
ac_cv_func_strchrnul=no ./configure --prefix=/tmp/pgbuild --without-readline --without-icu CFLAGS="-O2"
|
ac_cv_func_strchrnul=no ./configure --prefix=/tmp/pgbuild --without-readline --without-icu CFLAGS="-O2"
|
||||||
@@ -75,10 +141,19 @@ for b in pg_dump pg_restore psql; do
|
|||||||
libpq=$(otool -L "$OUT_PG/$b" | awk '/libpq/ {print $1; exit}')
|
libpq=$(otool -L "$OUT_PG/$b" | awk '/libpq/ {print $1; exit}')
|
||||||
install_name_tool -change "$libpq" "@loader_path/libpq.5.dylib" "$OUT_PG/$b"
|
install_name_tool -change "$libpq" "@loader_path/libpq.5.dylib" "$OUT_PG/$b"
|
||||||
done
|
done
|
||||||
|
# install_name_tool invalidates the linker's signature; re-sign with the
|
||||||
|
# Developer ID identity + hardened runtime + secure timestamp so the bundle
|
||||||
|
# passes notarization (Apple rejects ad-hoc-signed nested executables).
|
||||||
|
for f in "$OUT_PG"/pg_dump "$OUT_PG"/pg_restore "$OUT_PG"/psql "$OUT_PG"/libpq.5.dylib; do
|
||||||
|
codesign --force --options runtime --timestamp --sign "$SIGNING_IDENTITY" "$f"
|
||||||
|
done
|
||||||
|
|
||||||
echo ">> Building MariaDB client tools..."
|
echo ">> Building MariaDB client tools..."
|
||||||
mkdir -p "$OUT_MY"
|
mkdir -p "$OUT_MY"
|
||||||
MARIADB_VER="11.4.5"
|
MARIADB_VER="11.4.5"
|
||||||
|
# Idempotent re-runs: git clone fails if the destination is non-empty (e.g.
|
||||||
|
# left over from an earlier build on the same machine). Clean it first.
|
||||||
|
rm -rf /tmp/mariadb-server
|
||||||
git clone --depth 1 --branch "mariadb-${MARIADB_VER}" https://github.com/MariaDB/server.git /tmp/mariadb-server
|
git clone --depth 1 --branch "mariadb-${MARIADB_VER}" https://github.com/MariaDB/server.git /tmp/mariadb-server
|
||||||
cd /tmp/mariadb-server
|
cd /tmp/mariadb-server
|
||||||
if [ "$(uname -m)" = arm64 ]; then
|
if [ "$(uname -m)" = arm64 ]; then
|
||||||
@@ -112,7 +187,7 @@ for pass in 1 2 3 4 5 6; do
|
|||||||
if [ ! -f "$OUT_MY/$base" ]; then cp "$lib" "$OUT_MY/$base" 2>/dev/null || true; fi
|
if [ ! -f "$OUT_MY/$base" ]; then cp "$lib" "$OUT_MY/$base" 2>/dev/null || true; fi
|
||||||
install_name_tool -change "$lib" "@loader_path/$base" "$dylib" 2>/dev/null || true
|
install_name_tool -change "$lib" "@loader_path/$base" "$dylib" 2>/dev/null || true
|
||||||
done || true
|
done || true
|
||||||
codesign --force --sign - "$dylib" 2>/dev/null || true
|
codesign --force --options runtime --timestamp --sign "$SIGNING_IDENTITY" "$dylib"
|
||||||
done
|
done
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -130,6 +205,24 @@ VERSION="${TAG#v}"
|
|||||||
BUNDLE_ARM="${WORKSPACE}/desktop/src-tauri/target/aarch64-apple-darwin/release/bundle"
|
BUNDLE_ARM="${WORKSPACE}/desktop/src-tauri/target/aarch64-apple-darwin/release/bundle"
|
||||||
BUNDLE_INTEL="${WORKSPACE}/desktop/src-tauri/target/x86_64-apple-darwin/release/bundle"
|
BUNDLE_INTEL="${WORKSPACE}/desktop/src-tauri/target/x86_64-apple-darwin/release/bundle"
|
||||||
|
|
||||||
|
# ---- verify signing + notarization -----------------------------------------
|
||||||
|
# Confirm both .app bundles are properly signed and carry a stapled
|
||||||
|
# notarization ticket before we upload them. spctl can lag right after
|
||||||
|
# notarization (assessment cache), so stapler validate is the authoritative
|
||||||
|
# check; a pending spctl is only a warning.
|
||||||
|
for APP in \
|
||||||
|
"${BUNDLE_ARM}/macos/Gridline.app" \
|
||||||
|
"${BUNDLE_INTEL}/macos/Gridline.app"; do
|
||||||
|
[ -d "$APP" ] || { echo "missing $APP" >&2; exit 1; }
|
||||||
|
echo ">> Verifying $APP"
|
||||||
|
codesign --verify --deep --strict --verbose=2 "$APP" \
|
||||||
|
|| { echo "codesign verify FAILED" >&2; exit 1; }
|
||||||
|
xcrun stapler validate "$APP" \
|
||||||
|
|| { echo "notarization ticket not stapled" >&2; exit 1; }
|
||||||
|
spctl --assess --type execute --verbose=4 "$APP" \
|
||||||
|
|| echo " (spctl assessment pending — normal right after notarization; stapler validate passed)"
|
||||||
|
done
|
||||||
|
|
||||||
# ---- create/ensure release + upload DMGs -----------------------------------
|
# ---- create/ensure release + upload DMGs -----------------------------------
|
||||||
API="${GITEA_SERVER}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}"
|
API="${GITEA_SERVER}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}"
|
||||||
AUTH="Authorization: token ${GITEA_TOKEN}"
|
AUTH="Authorization: token ${GITEA_TOKEN}"
|
||||||
|
|||||||
@@ -10,9 +10,7 @@ export default defineConfig({
|
|||||||
compressHTML: true,
|
compressHTML: true,
|
||||||
trailingSlash: 'ignore',
|
trailingSlash: 'ignore',
|
||||||
integrations: [
|
integrations: [
|
||||||
sitemap({
|
sitemap(),
|
||||||
lastmod: new Date(),
|
|
||||||
}),
|
|
||||||
react(),
|
react(),
|
||||||
],
|
],
|
||||||
vite: {
|
vite: {
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
/* /404.html 404
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
+3
-20
@@ -60,7 +60,7 @@
|
|||||||
const keepLabel = downloadBtn.hasAttribute("data-download-label");
|
const keepLabel = downloadBtn.hasAttribute("data-download-label");
|
||||||
|
|
||||||
const releasesUrl = "https://git.ranio.xyz/adrianbonpin/gridline/releases";
|
const releasesUrl = "https://git.ranio.xyz/adrianbonpin/gridline/releases";
|
||||||
const version = "0.7.12";
|
const version = "0.7.15";
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
downloadBtn.href = releasesUrl;
|
downloadBtn.href = releasesUrl;
|
||||||
@@ -68,9 +68,6 @@
|
|||||||
} else if (isMac) {
|
} else if (isMac) {
|
||||||
downloadBtn.href = `${releasesUrl}/download/v${version}/Gridline_${version}_aarch64.dmg`;
|
downloadBtn.href = `${releasesUrl}/download/v${version}/Gridline_${version}_aarch64.dmg`;
|
||||||
if (!keepLabel) downloadBtn.textContent = "Download for macOS";
|
if (!keepLabel) downloadBtn.textContent = "Download for macOS";
|
||||||
if (downloadBtn.id === "download") {
|
|
||||||
document.getElementById("macos-hint")?.classList.remove("hidden");
|
|
||||||
}
|
|
||||||
} else if (isWin) {
|
} else if (isWin) {
|
||||||
downloadBtn.href = `${releasesUrl}/download/v${version}/Gridline_${version}_x64-setup.exe`;
|
downloadBtn.href = `${releasesUrl}/download/v${version}/Gridline_${version}_x64-setup.exe`;
|
||||||
if (!keepLabel) downloadBtn.textContent = "Download for Windows";
|
if (!keepLabel) downloadBtn.textContent = "Download for Windows";
|
||||||
@@ -83,18 +80,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("copy-quarantine")?.addEventListener("click", async () => {
|
|
||||||
const command = "sudo xattr -dr com.apple.quarantine /Applications/Gridline.app";
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(command);
|
|
||||||
const btn = document.getElementById("copy-quarantine");
|
|
||||||
if (!btn) return;
|
|
||||||
const original = btn.textContent;
|
|
||||||
btn.textContent = "Copied";
|
|
||||||
setTimeout(() => (btn.textContent = original), 1500);
|
|
||||||
} catch {}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Nav scroll background
|
// Nav scroll background
|
||||||
const nav = document.getElementById("nav");
|
const nav = document.getElementById("nav");
|
||||||
function onScroll() {
|
function onScroll() {
|
||||||
@@ -141,24 +126,22 @@
|
|||||||
const subtitle = hero.querySelector(".hero-animate > p:nth-of-type(2)");
|
const subtitle = hero.querySelector(".hero-animate > p:nth-of-type(2)");
|
||||||
const buttons = hero.querySelector(".hero-animate > div");
|
const buttons = hero.querySelector(".hero-animate > div");
|
||||||
const screenshot = hero.querySelector(".hero-screenshot");
|
const screenshot = hero.querySelector(".hero-screenshot");
|
||||||
const macosHint = hero.querySelector("#macos-hint");
|
|
||||||
|
|
||||||
if (reducedMotion) {
|
if (reducedMotion) {
|
||||||
// Reduced motion: reveal the hero immediately (CSS hides it at first
|
// Reduced motion: reveal the hero immediately (CSS hides it at first
|
||||||
// paint to avoid flicker, but the timeline below is skipped). Section
|
// paint to avoid flicker, but the timeline below is skipped). Section
|
||||||
// reveals below still run through the IntersectionObserver.
|
// reveals below still run through the IntersectionObserver.
|
||||||
anime.set([eyebrow, h1, subtitle, buttons, screenshot, macosHint], { opacity: 1, translateY: 0 });
|
anime.set([eyebrow, h1, subtitle, buttons, screenshot], { opacity: 1, translateY: 0 });
|
||||||
} else {
|
} else {
|
||||||
const heroTl = anime.timeline({ easing: "easeOutExpo" });
|
const heroTl = anime.timeline({ easing: "easeOutExpo" });
|
||||||
|
|
||||||
anime.set([eyebrow, h1, subtitle, buttons, screenshot, macosHint], { opacity: 0, translateY: 20 });
|
anime.set([eyebrow, h1, subtitle, buttons, screenshot], { opacity: 0, translateY: 20 });
|
||||||
anime.set(screenshot, { opacity: 0, scale: 0.96, translateY: 0 });
|
anime.set(screenshot, { opacity: 0, scale: 0.96, translateY: 0 });
|
||||||
|
|
||||||
if (eyebrow) heroTl.add({ targets: eyebrow, opacity: [0, 1], translateY: [15, 0] }, 0);
|
if (eyebrow) heroTl.add({ targets: eyebrow, opacity: [0, 1], translateY: [15, 0] }, 0);
|
||||||
if (h1) heroTl.add({ targets: h1, opacity: [0, 1], translateY: [30, 0], duration: 800 }, 100);
|
if (h1) heroTl.add({ targets: h1, opacity: [0, 1], translateY: [30, 0], duration: 800 }, 100);
|
||||||
if (subtitle) heroTl.add({ targets: subtitle, opacity: [0, 1], translateY: [20, 0] }, 250);
|
if (subtitle) heroTl.add({ targets: subtitle, opacity: [0, 1], translateY: [20, 0] }, 250);
|
||||||
if (buttons) heroTl.add({ targets: buttons, opacity: [0, 1], translateY: [20, 0] }, 350);
|
if (buttons) heroTl.add({ targets: buttons, opacity: [0, 1], translateY: [20, 0] }, 350);
|
||||||
if (macosHint) heroTl.add({ targets: macosHint, opacity: [0, 1], translateY: [20, 0] }, 400);
|
|
||||||
if (screenshot) heroTl.add({ targets: screenshot, opacity: [0, 1], scale: [0.96, 1], duration: 900 }, 450);
|
if (screenshot) heroTl.add({ targets: screenshot, opacity: [0, 1], scale: [0.96, 1], duration: 900 }, 450);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,12 @@
|
|||||||
"sizes": "any",
|
"sizes": "any",
|
||||||
"type": "image/svg+xml",
|
"type": "image/svg+xml",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/apple-touch-icon.png",
|
||||||
|
"sizes": "180x180",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,31 @@ export interface ChangelogEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const changelogEntries: ChangelogEntry[] = [
|
export const changelogEntries: ChangelogEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.7.15",
|
||||||
|
date: "2026-09-08",
|
||||||
|
highlights: [
|
||||||
|
"Insert Row now works — it adds an editable pending row at the top of the data grid (with a visible accent outline), and committing inserts only the columns you filled in so serial/identity/generated defaults apply.",
|
||||||
|
"Smart cell editors — date/time/datetime pickers, a boolean select, and number inputs for the matching column types (enums and foreign keys already had dropdowns), applied to both new rows and inline editing.",
|
||||||
|
"Landing page adds a support channel: coffee via Ko-fi (ko-fi.com/adrianbonpin), always optional, never gates a feature.",
|
||||||
|
"Landing page copy refreshed: em dashes dropped, OSS section simplified, FAQ added for how the project is funded.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "0.7.14",
|
||||||
|
date: "2026-09-04",
|
||||||
|
highlights: [
|
||||||
|
"No more repeated macOS keychain prompts — saved connection passwords and SSH secrets are now ACL-pinned to the app's code signature when stored, so app upgrades never re-trigger the macOS keychain access dialog (users upgrading from older builds see one final prompt — choose Always Allow).",
|
||||||
|
"Install via Homebrew — add the Gitea tap (brew tap AdrianBonpin/gridline https://git.ranio.xyz/adrianbonpin/homebrew-gridline.git) and run brew install --cask gridline.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "0.7.13",
|
||||||
|
date: "2026-09-04",
|
||||||
|
highlights: [
|
||||||
|
"macOS is now Developer-ID signed and notarized — the app is properly code-signed with a Developer ID Application certificate and notarized by Apple, so macOS opens it without the Gatekeeper 'Open Anyway' prompt or the xattr quarantine workaround.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.7.12",
|
version: "0.7.12",
|
||||||
date: "2026-09-01",
|
date: "2026-09-01",
|
||||||
|
|||||||
+9
-4
@@ -7,12 +7,12 @@ export const faqItems: FaqItem[] = [
|
|||||||
{
|
{
|
||||||
question: "Is Gridline really free for commercial use?",
|
question: "Is Gridline really free for commercial use?",
|
||||||
answer:
|
answer:
|
||||||
"Yes. Gridline is released under the Apache 2.0 license. You can use it for personal, commercial, or internal company work without paying or signing up.",
|
"Yes. Gridline is Apache 2.0. You can use it for personal, commercial, or internal work without paying or signing up.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: "Which databases are supported?",
|
question: "Which databases are supported?",
|
||||||
answer:
|
answer:
|
||||||
"PostgreSQL and SQLite are fully supported today, including browsing, querying, editing, backups, and visual tools. MySQL and Redis connections are in active development.",
|
"PostgreSQL and SQLite are fully supported today (browsing, querying, editing, backups, and the visual tools). MySQL and Redis connections are in active development.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: "How is this different from TablePlus, Beekeeper Studio, or DB Pro?",
|
question: "How is this different from TablePlus, Beekeeper Studio, or DB Pro?",
|
||||||
@@ -22,10 +22,15 @@ export const faqItems: FaqItem[] = [
|
|||||||
{
|
{
|
||||||
question: "Do you store my database credentials?",
|
question: "Do you store my database credentials?",
|
||||||
answer:
|
answer:
|
||||||
"Passwords are stored in your operating system keychain — macOS Keychain, Linux Secret Service, or Windows Credential Manager — never in Gridline's local SQLite database.",
|
"No. Passwords live in your operating system keychain (macOS Keychain, Linux Secret Service, or Windows Credential Manager), never in Gridline's local SQLite database.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: "Can I self-host or contribute?",
|
question: "How do you make money?",
|
||||||
|
answer:
|
||||||
|
"Gridline is free and isn't built around a paywall. If it saves you time, you can buy a coffee at ko-fi.com/adrianbonpin. It's entirely optional and never gates a feature. Otherwise, issues, docs, and contributions all help.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Can I contribute or self-host?",
|
||||||
answer:
|
answer:
|
||||||
"Gridline is a desktop app, not a SaaS, so there's nothing to self-host. The repo is open for contributions: code, docs, translations, and issue reports are all welcome.",
|
"Gridline is a desktop app, not a SaaS, so there's nothing to self-host. The repo is open for contributions: code, docs, translations, and issue reports are all welcome.",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export interface Props {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
title,
|
title,
|
||||||
description = "Gridline — the open-source database GUI for PostgreSQL, MySQL, SQLite, and Redis. No paywalls, no tab limits, fully open source.",
|
description = "Gridline, the open-source database GUI for PostgreSQL, MySQL, SQLite, and Redis. No paywalls, no tab limits, fully open source.",
|
||||||
ogImage = "/og.png",
|
ogImage = "/og.png",
|
||||||
noIndex = false,
|
noIndex = false,
|
||||||
jsonLd = [],
|
jsonLd = [],
|
||||||
@@ -36,8 +36,13 @@ const isProd = import.meta.env.PROD;
|
|||||||
<link rel="canonical" href={canonicalURL} />
|
<link rel="canonical" href={canonicalURL} />
|
||||||
<link rel="sitemap" href="/sitemap-index.xml" />
|
<link rel="sitemap" href="/sitemap-index.xml" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/gridline-icon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/gridline-icon.svg" />
|
||||||
<link rel="apple-touch-icon" href="/gridline-icon.svg" />
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
<link rel="manifest" href="/site.webmanifest" />
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
|
<link rel="preload" as="image" href="/og.png" />
|
||||||
|
<link rel="preconnect" href="https://stat.ranio.xyz" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://git.ranio.xyz" />
|
||||||
|
<link rel="dns-prefetch" href="https://stat.ranio.xyz" />
|
||||||
|
<link rel="dns-prefetch" href="https://git.ranio.xyz" />
|
||||||
|
|
||||||
<meta property="og:title" content={title} />
|
<meta property="og:title" content={title} />
|
||||||
<meta property="og:description" content={description} />
|
<meta property="og:description" content={description} />
|
||||||
@@ -46,7 +51,7 @@ const isProd = import.meta.env.PROD;
|
|||||||
<meta property="og:image" content={ogImageURL} />
|
<meta property="og:image" content={ogImageURL} />
|
||||||
<meta property="og:image:width" content="1200" />
|
<meta property="og:image:width" content="1200" />
|
||||||
<meta property="og:image:height" content="630" />
|
<meta property="og:image:height" content="630" />
|
||||||
<meta property="og:image:alt" content="Gridline — the open-source database GUI" />
|
<meta property="og:image:alt" content="Gridline, the open-source database GUI" />
|
||||||
<meta property="og:site_name" content="Gridline" />
|
<meta property="og:site_name" content="Gridline" />
|
||||||
<meta property="og:locale" content="en_US" />
|
<meta property="og:locale" content="en_US" />
|
||||||
|
|
||||||
@@ -54,6 +59,7 @@ const isProd = import.meta.env.PROD;
|
|||||||
<meta name="twitter:title" content={title} />
|
<meta name="twitter:title" content={title} />
|
||||||
<meta name="twitter:description" content={description} />
|
<meta name="twitter:description" content={description} />
|
||||||
<meta name="twitter:image" content={ogImageURL} />
|
<meta name="twitter:image" content={ogImageURL} />
|
||||||
|
<meta name="twitter:image:alt" content="Gridline, the open-source database GUI" />
|
||||||
<meta name="twitter:site" content="@AdrianBonpin" />
|
<meta name="twitter:site" content="@AdrianBonpin" />
|
||||||
<meta name="twitter:creator" content="@AdrianBonpin" />
|
<meta name="twitter:creator" content="@AdrianBonpin" />
|
||||||
|
|
||||||
@@ -143,12 +149,13 @@ const isProd = import.meta.env.PROD;
|
|||||||
<ul class="space-y-2 text-sm text-[var(--text-muted)]">
|
<ul class="space-y-2 text-sm text-[var(--text-muted)]">
|
||||||
<li><a href="https://x.com/AdrianBonpin" target="_blank" rel="noopener" class="footer-link">X / Twitter</a></li>
|
<li><a href="https://x.com/AdrianBonpin" target="_blank" rel="noopener" class="footer-link">X / Twitter</a></li>
|
||||||
<li><a href="mailto:adrianbonpin@gmail.com" class="footer-link">Email</a></li>
|
<li><a href="mailto:adrianbonpin@gmail.com" class="footer-link">Email</a></li>
|
||||||
|
<li><a href="https://ko-fi.com/adrianbonpin" target="_blank" rel="noopener" class="footer-link">Support on Ko-fi ☕</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-12 pt-8 border-t border-[var(--border)] flex flex-col md:flex-row justify-between gap-3 text-xs text-[var(--text-muted)] font-mono">
|
<div class="mt-12 pt-8 border-t border-[var(--border)] flex flex-col md:flex-row justify-between gap-3 text-xs text-[var(--text-muted)] font-mono">
|
||||||
<span>© 2026 Gridline contributors · Apache 2.0</span>
|
<span>© 2026 Gridline contributors · Apache 2.0</span>
|
||||||
<span>Made with care for databases.</span>
|
<span>Coffee-powered.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
buildWebSiteJsonLd,
|
buildWebSiteJsonLd,
|
||||||
buildBreadcrumbJsonLd,
|
buildBreadcrumbJsonLd,
|
||||||
buildTitle,
|
buildTitle,
|
||||||
|
buildProductJsonLd,
|
||||||
} from "./seo";
|
} from "./seo";
|
||||||
|
|
||||||
describe("seo builders", () => {
|
describe("seo builders", () => {
|
||||||
@@ -70,4 +71,18 @@ describe("seo builders", () => {
|
|||||||
it("buildTitle appends the brand", () => {
|
it("buildTitle appends the brand", () => {
|
||||||
expect(buildTitle("PostgreSQL GUI")).toBe("PostgreSQL GUI | Gridline");
|
expect(buildTitle("PostgreSQL GUI")).toBe("PostgreSQL GUI | Gridline");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("buildProductJsonLd returns valid Product schema", () => {
|
||||||
|
const ld = buildProductJsonLd({
|
||||||
|
name: "Gridline",
|
||||||
|
url: "https://getgridline.app/",
|
||||||
|
description: "Open-source database GUI",
|
||||||
|
image: "https://getgridline.app/og.png",
|
||||||
|
offersPrice: 0,
|
||||||
|
offersCurrency: "USD",
|
||||||
|
});
|
||||||
|
expect(ld["@type"]).toBe("Product");
|
||||||
|
expect(ld.name).toBe("Gridline");
|
||||||
|
expect((ld.offers as { price: number }).price).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -108,3 +108,26 @@ export function buildBreadcrumbJsonLd(
|
|||||||
export function buildTitle(primary: string, brand = "Gridline"): string {
|
export function buildTitle(primary: string, brand = "Gridline"): string {
|
||||||
return `${primary} | ${brand}`;
|
return `${primary} | ${brand}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildProductJsonLd(opts: {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
description: string;
|
||||||
|
image: string;
|
||||||
|
offersPrice: number;
|
||||||
|
offersCurrency: string;
|
||||||
|
}): JsonLd {
|
||||||
|
return {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Product",
|
||||||
|
name: opts.name,
|
||||||
|
url: opts.url,
|
||||||
|
description: opts.description,
|
||||||
|
image: opts.image,
|
||||||
|
offers: {
|
||||||
|
"@type": "Offer",
|
||||||
|
price: opts.offersPrice,
|
||||||
|
priceCurrency: opts.offersCurrency,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
import Layout from "../layouts/Layout.astro";
|
import Layout from "../layouts/Layout.astro";
|
||||||
|
|
||||||
const title = "Page not found — Gridline";
|
const title = "Page not found · Gridline";
|
||||||
const description = "That page doesn’t exist. Head back to Gridline, the open-source database GUI.";
|
const description = "That page doesn’t exist. Head back to Gridline, the open-source database GUI.";
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+29
-15
@@ -6,6 +6,8 @@ import {
|
|||||||
buildFaqJsonLd,
|
buildFaqJsonLd,
|
||||||
buildOrganizationJsonLd,
|
buildOrganizationJsonLd,
|
||||||
buildWebSiteJsonLd,
|
buildWebSiteJsonLd,
|
||||||
|
buildProductJsonLd,
|
||||||
|
buildBreadcrumbJsonLd,
|
||||||
} from "../lib/seo";
|
} from "../lib/seo";
|
||||||
import { Image } from "astro:assets";
|
import { Image } from "astro:assets";
|
||||||
import HeroDbViewer from "../components/hero/HeroDbViewer";
|
import HeroDbViewer from "../components/hero/HeroDbViewer";
|
||||||
@@ -13,7 +15,7 @@ import queryEditor from "../assets/screenshots/query-editor.png";
|
|||||||
import erDiagram from "../assets/screenshots/er-diagram.png";
|
import erDiagram from "../assets/screenshots/er-diagram.png";
|
||||||
import home from "../assets/screenshots/home.png";
|
import home from "../assets/screenshots/home.png";
|
||||||
import dataGrid from "../assets/screenshots/data-grid.png";
|
import dataGrid from "../assets/screenshots/data-grid.png";
|
||||||
const version = "0.7.12";
|
const version = "0.7.15";
|
||||||
const site = "https://getgridline.app";
|
const site = "https://getgridline.app";
|
||||||
const jsonLd = [
|
const jsonLd = [
|
||||||
buildSoftwareApplicationJsonLd({
|
buildSoftwareApplicationJsonLd({
|
||||||
@@ -42,9 +44,21 @@ const jsonLd = [
|
|||||||
name: "Gridline",
|
name: "Gridline",
|
||||||
url: `${site}/`,
|
url: `${site}/`,
|
||||||
}),
|
}),
|
||||||
|
buildProductJsonLd({
|
||||||
|
name: "Gridline",
|
||||||
|
url: `${site}/`,
|
||||||
|
description:
|
||||||
|
"The open-source database GUI for PostgreSQL, MySQL, SQLite, and Redis. No paywalls, no tab limits, fully open source.",
|
||||||
|
image: `${site}/og.png`,
|
||||||
|
offersPrice: 0,
|
||||||
|
offersCurrency: "USD",
|
||||||
|
}),
|
||||||
|
buildBreadcrumbJsonLd([
|
||||||
|
{ name: "Home", url: `${site}/` },
|
||||||
|
]),
|
||||||
];
|
];
|
||||||
---
|
---
|
||||||
<Layout title="Gridline — Open-Source Database GUI" jsonLd={jsonLd}>
|
<Layout title="Gridline, Free Open-Source Database GUI" jsonLd={jsonLd}>
|
||||||
<script src="/landing.js" defer></script>
|
<script src="/landing.js" defer></script>
|
||||||
|
|
||||||
<!-- Hero -->
|
<!-- Hero -->
|
||||||
@@ -56,7 +70,7 @@ const jsonLd = [
|
|||||||
the open-source database <span class="text-[var(--accent)]">gui</span>
|
the open-source database <span class="text-[var(--accent)]">gui</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p class="mt-6 text-lg md:text-xl text-[var(--text-muted)] max-w-xl mx-auto">
|
<p class="mt-6 text-lg md:text-xl text-[var(--text-muted)] max-w-xl mx-auto">
|
||||||
Query, explore, sync, and edit PostgreSQL, MySQL, SQLite, and Redis — with no paywalls, no tab limits, no account required.
|
Query, explore, sync, and edit PostgreSQL, MySQL, SQLite, and Redis, with no paywalls, no tab limits, no account required.
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||||
<a id="download" href="https://git.ranio.xyz/adrianbonpin/gridline/releases" class="primary-btn" data-download>
|
<a id="download" href="https://git.ranio.xyz/adrianbonpin/gridline/releases" class="primary-btn" data-download>
|
||||||
@@ -66,14 +80,6 @@ const jsonLd = [
|
|||||||
View on Gitea
|
View on Gitea
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="macos-hint" class="hidden max-w-lg mx-auto w-full rounded-xl border border-[var(--border)] bg-[var(--surface)] p-3 text-left mt-4">
|
|
||||||
<p class="text-xs text-[var(--text-muted)] mb-2">First launch on macOS? Run this in Terminal:</p>
|
|
||||||
<div class="flex items-center justify-between gap-3 rounded-lg bg-[var(--bg)] border border-[var(--border)] px-3 py-2">
|
|
||||||
<code class="font-mono text-xs truncate">sudo xattr -dr com.apple.quarantine /Applications/Gridline.app</code>
|
|
||||||
<button id="copy-quarantine" class="text-xs font-medium text-[var(--primary)] hover:text-[var(--primary-hover)] transition-colors shrink-0" aria-label="Copy command">Copy</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hero-screenshot mt-14 md:mt-20 mx-auto max-w-6xl">
|
<div class="hero-screenshot mt-14 md:mt-20 mx-auto max-w-6xl">
|
||||||
@@ -170,13 +176,21 @@ const jsonLd = [
|
|||||||
<section id="oss" class="section-animate py-20 md:py-28 text-center">
|
<section id="oss" class="section-animate py-20 md:py-28 text-center">
|
||||||
<div class="max-w-[800px] mx-auto px-6">
|
<div class="max-w-[800px] mx-auto px-6">
|
||||||
<p class="font-mono text-xs text-[var(--accent)] uppercase tracking-[0.15em] mb-3">Open Source</p>
|
<p class="font-mono text-xs text-[var(--accent)] uppercase tracking-[0.15em] mb-3">Open Source</p>
|
||||||
<h2 class="font-serif text-3xl md:text-5xl mb-5">Built in public.<br />No VC roadmap, no rug pulls.</h2>
|
<h2 class="font-serif text-3xl md:text-5xl mb-5">Free. Open source. Apache 2.0.</h2>
|
||||||
<p class="text-[var(--text-muted)] mb-8 max-w-md mx-auto">
|
<p class="text-[var(--text-muted)] mb-8 max-w-md mx-auto">
|
||||||
Gridline is Apache 2.0 and developed openly. Contributions, issues, and feature requests are welcome.
|
Gridline is Apache 2.0 and developed openly. Contributions, issues, and feature requests are welcome.
|
||||||
</p>
|
</p>
|
||||||
<a href="https://git.ranio.xyz/adrianbonpin/gridline" target="_blank" rel="noopener" class="ghost-btn">
|
<div class="flex flex-wrap items-center justify-center gap-3">
|
||||||
Star on Gitea
|
<a href="https://git.ranio.xyz/adrianbonpin/gridline" target="_blank" rel="noopener" class="ghost-btn">
|
||||||
</a>
|
Star on Gitea
|
||||||
|
</a>
|
||||||
|
<a href="https://ko-fi.com/adrianbonpin" target="_blank" rel="noopener" class="ghost-btn">
|
||||||
|
☕ Support on Ko-fi
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p class="mt-6 text-xs text-[var(--text-muted)] max-w-sm mx-auto">
|
||||||
|
Gridline stays free. No feature is ever behind a payment. Ko-fi is only for those who want to say thanks.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user