From 3e6c5233f3b6cb8cd2590aa479f6a47b898b699e Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 14 Jul 2026 22:29:15 +0545 Subject: [PATCH 1/4] docs: refresh README with full engine + feature coverage - Add ClickHouse, DuckDB, and SQL Server to supported databases - Add Prisma Postgres to one-click providers; group core engines vs providers - Document live mode, global search, EXPLAIN plans, relation tree, and schema-object creation dialogs - Correct the backup/restore section (SQL-dump based with include toggles, per-table selection, live log, cancel; PG/MySQL/SQLite/D1) - Note OS-keychain credential storage; mention ECharts/choropleth charts - Expand the Connecting section to cover every engine and provider --- README.md | 97 +++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 66 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 370cf4ca..59fca2c6 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ **A fast, minimal desktop database client.** -Connect to PostgreSQL, MySQL, SQLite, Turso/LibSQL, Cloudflare D1, and more — -browse schemas, edit data, write SQL, visualize, and let AI tools talk to your database through a built-in MCP server. +Connect to PostgreSQL, MySQL, SQLite, Turso/LibSQL, Cloudflare D1, ClickHouse, DuckDB, and SQL Server — +browse schemas, edit data, write SQL, visualize results, and let AI tools talk to your database through a built-in MCP server. [Download](#install) · [Features](#features) · [Build from source](#build-from-source) · [Website](https://stroke.click) @@ -21,6 +21,7 @@ Built with **Rust + Svelte** — a native backend paired with a reactive UI — - **Handles millions of rows** without slowing down - **Keyboard-first** — every feature is reachable without a mouse - **Read-only mode** — safely browse production without risking accidental writes +- **AI-native** — a built-in assistant and an MCP server your other AI tools can connect to - **Extensions** — expand functionality with plugins - **Themes** — dark and light, out of the box @@ -28,18 +29,32 @@ Built with **Rust + Svelte** — a native backend paired with a reactive UI — ## Supported databases +**Core engines** + | Database | Notes | |----------|-------| | **PostgreSQL** | Full schema, enums, sequences, triggers, indexes | | **MySQL / MariaDB** | Standard host/port connections | | **SQLite** | Local file or `:memory:` | | **Turso / LibSQL** | Serverless SQLite at the edge | -| **Cloudflare D1** | OAuth or API token | -| **CockroachDB** | PostgreSQL-compatible | +| **Cloudflare D1** | OAuth sign-in or API token | +| **ClickHouse** | Columnar OLAP over HTTP(S) | +| **DuckDB** | Embedded analytical database (local file) | +| **Microsoft SQL Server** | Host/port connections | + +**One-click providers** + +Sign in and pick a database — no connection string to assemble: + +| Provider | Based on | +|----------|----------| | **Neon** | Serverless Postgres | -| **Supabase** | Postgres-based backend | +| **Supabase** | Postgres | +| **Prisma Postgres** | Serverless Postgres | | **PlanetScale** | MySQL-compatible serverless | +**CockroachDB** and other PostgreSQL-compatible databases connect through the PostgreSQL option. + Connect directly or through an **SSH tunnel** for databases behind a bastion host or private network. --- @@ -49,18 +64,18 @@ Connect directly or through an **SSH tunnel** for databases behind a bastion hos ### Data grid - Paginated browsing with configurable page size +- Rows paint immediately; the total count (`… of N`) fills in the background - Resizable columns, saved per table - Pin columns to keep them in view while scrolling -- Show/hide columns -- Reset column width to default +- Show/hide columns and reset column width to default - Column statistics — min, max, avg, nulls, distinct count -- Multi-column sort -- Full-text search and visual filter builder +- Multi-column sort — shift-click headers to add secondary keys +- Full-text search and a visual filter builder, with relative date-range presets and enum value pickers - Click any foreign-key value to jump to the referenced row **Cell right-click menu** -Open, Edit, Copy, Filter by value, Exclude this value, Set NULL, Expand, Select row, Delete row +Open · Edit · Copy · Filter by value · Exclude this value · Set NULL · Expand · Select row · Delete row Copy row as → JSON · CSV · Plain text · Markdown table · INSERT statement **Column header right-click menu** @@ -69,29 +84,39 @@ Sort ascending · Sort descending · Filter by this column · Pin column · Hide ### Inline editing -Edit text, numbers, booleans, enums, dates, UUIDs, and JSON in place. -Insert rows with smart defaults. Delete rows. Set any cell to NULL in one action. +Edit text, numbers, booleans, enums, dates, UUIDs, arrays, and JSON in place. +Insert rows with smart defaults, delete rows, and set any cell to NULL in one action. ### Row inspector -View any row in formatted, raw JSON, or preview mode. +View any row in formatted, raw JSON, or preview mode — with a rich viewer for nested JSON, arrays, and media/URL values. ### Schema explorer - Tables, views, materialized views, and foreign tables - Live row counts in the sidebar -- Index browser -- View DDL for any object +- Index browser and DDL viewer for any object +- Create tables, schemas, sequences, triggers, enums, and foreign keys through guided dialogs +- Manage PostgreSQL enum types + +### Relation tree -**Table right-click menu** +Walk foreign-key relationships as an expandable tree, drilling from a row into everything it references — row counts stream in without blocking navigation. -Copy name · Close tab · Pin table · Deselect · View DDL · Export as SQL · Export data · Generate test data · Truncate table · Drop table +### Global search + +Search across tables and objects to find what you need without hunting through the sidebar. + +### Live mode + +Watch a table for real-time changes — inserts, updates, and deletes stream into the grid as they happen on the database. ### SQL console - Syntax highlighting and schema-aware autocomplete -- Query formatting and execution time -- CSV and JSON export +- Query formatting and execution timing +- **EXPLAIN plans** — visualize the query planner's execution plan +- CSV, JSON, and SQL export of results - Automatic query history — everything is saved - Saved queries — bookmark the ones you keep - SQL Notebooks (`.sqlnb`) — multi-cell notebooks for documenting and replaying query sequences @@ -113,20 +138,21 @@ Open the quick-access screen to jump anywhere in one keystroke: | **Diagrams** | Auto-generated ERDs | | **Timeline** | Schema drift detection across snapshots | | **Data Diff** | Row-level diff between snapshots or queries | +| **Search** | Global search across the database | | **Extensions** | Install and manage plugins | | **Connect** | Manage and switch connections | ### Charts & dashboards -Turn any query result into a chart and pin it to a dashboard. +Turn any query result into a chart — bar, line, area, pie, and geographic **choropleth** maps (powered by ECharts) — and pin it to a dashboard. ### Schema diagrams -Auto-generated entity-relationship diagrams (ERDs) built from your live schema. +Auto-generated entity-relationship diagrams (ERDs) built from your live schema, with an interactive canvas. ### Schema timeline -Track schema changes over time. See exactly what columns, indexes, or constraints were added or removed between snapshots. +Track schema changes over time. See exactly which columns, indexes, or constraints were added or removed between snapshots. ### Data diff @@ -140,17 +166,15 @@ Write ORM-style queries, preview the generated SQL, and run them against the con Inspect roles, grants, and privileges — see who can read or write what, at a glance. -### Status bar - -Active connection, database name, row counts, query execution time, and connection health — always visible at the bottom. +### Backup & restore -### Docker launch +Export a full **SQL dump** — schema DDL, data, views, triggers, functions, sequences, and enums — with granular include toggles and per-table selection. A live log streams progress, and any export can be stopped mid-run. Restore by executing a `.sql` file, with per-statement results and clear error reporting. -Spin up a local Postgres or MySQL container in one click without leaving the app. +Available for **PostgreSQL, MySQL, SQLite, and Cloudflare D1**. -### Backup & restore +### Docker launch -Export and import data as SQL dumps or CSV. +Spin up a local PostgreSQL or MySQL container in one click without leaving the app. ### Extensions @@ -162,7 +186,7 @@ Lock any connection so writes are blocked entirely — safe for browsing product ### AI chat -An AI assistant with direct database access that runs queries, explains schemas, generates SQL, and renders diagrams and charts inline. +An AI assistant with direct database access that runs queries, explains schemas, generates SQL, and renders diagrams and charts inline. Works with any OpenAI-compatible API — configure a base URL, model, and API key in **Settings**. ### MCP server @@ -179,6 +203,14 @@ Uses a stable bearer token — configure once and it just works. `Cmd/Ctrl+K` — type anything (table name, feature, shortcut) to jump there instantly. +### Status bar + +Active connection, database name, row counts, query execution time, and connection health — always visible at the bottom. + +### Security & storage + +AI keys and provider OAuth tokens are stored in the **OS keychain** (macOS Keychain, Windows Credential Manager, or the Linux Secret Service), and migrated automatically from any older plaintext store. + --- ## Install @@ -238,9 +270,12 @@ chmod +x stroke_*_amd64.AppImage Pick a database type and fill in your credentials. Hit **Test connection**, then **Connect**. - **PostgreSQL / MySQL** — host, port, database, user, password, optional SSL. Paste a full connection string and click **Parse** to fill the form automatically. -- **SQLite** — point to a `.db`/`.sqlite` file, or use `:memory:`. +- **SQLite / DuckDB** — point to a database file (`.db`, `.sqlite`, `.duckdb`), or use `:memory:` for SQLite. - **Turso / LibSQL** — database URL and optional auth token. - **Cloudflare D1** — sign in with Cloudflare, or enter Account ID, Database ID, and an API token. +- **ClickHouse** — host, port, database, user, password, optional TLS. +- **SQL Server** — host, port, database, user, password. +- **Neon / Supabase / Prisma / PlanetScale** — sign in with the provider and pick a database. - **SSH tunnel** — any connection type can go through an SSH tunnel for databases in private networks. Connections are saved locally. Stroke reopens your last connection on launch. From ad6ec7c7d42af806a996f7d638b6a52affa6e088 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 14 Jul 2026 18:07:28 +0545 Subject: [PATCH 2/4] feat: Postgres array editor, keyring persistence fix, table styles & UI polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebased from feat/impfixes onto master (the original 'chore: working on capacitor' commit — misnamed; it carries finished features, not capacitor). Dropped stray .claude/phobos-* tooling files. - Postgres array columns: decode varchar[]/int[]/… into JSON arrays (was garbage bytes), with a dedicated Prisma-style ArrayCellEditor (add/remove/ reorder) that writes a cast array literal. - Keyring: enable per-platform backend features so saved credentials actually persist (v3 without a backend silently used a non-persistent in-memory mock). - Data table: 6 selectable grid styles (lines/bordered/striped/dotted/dots/minimal). - Editable prettified DML preview before apply; auto-reconnect on startup. - More keyboard shortcuts (reopen tab, go-to-tab 1–9, toggle tab bar, disconnect). - Connection screen / provider sign-in polish. --- package-lock.json | 4 +- src-tauri/Cargo.lock | 275 ++++++++++++++++- src-tauri/Cargo.toml | 8 +- src-tauri/src/db/query.rs | 88 ++++++ src-tauri/src/secrets.rs | 67 ++++- src/app.css | 7 + src/lib/components/ArrayCellEditor.svelte | 229 ++++++++++++++ src/lib/components/CloudflareLogin.svelte | 109 ++++--- src/lib/components/ConnectionModal.svelte | 127 +++++--- src/lib/components/DataTable.svelte | 284 ++++++++++++++---- .../components/KeyboardShortcutsDialog.svelte | 5 + src/lib/components/ProviderConnect.svelte | 96 ++++-- src/lib/components/ReportIssueDialog.svelte | 14 +- src/lib/components/SettingsDialog.svelte | 49 +++ src/lib/components/Sidebar.svelte | 2 +- src/lib/components/StudioShell.svelte | 60 ++++ src/lib/components/UpdateDialog.svelte | 16 +- src/lib/stores/settings.js | 46 ++- 18 files changed, 1286 insertions(+), 200 deletions(-) create mode 100644 src/lib/components/ArrayCellEditor.svelte diff --git a/package-lock.json b/package-lock.json index 63b53fc9..35414231 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "stroke", - "version": "1.7.0", + "version": "1.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "stroke", - "version": "1.7.0", + "version": "1.10.0", "dependencies": { "@dagrejs/dagre": "^3.0.0", "@fontsource-variable/geist": "^5.2.9", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ebbd12de..ab9f55dd 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,6 +8,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "ahash" version = "0.7.8" @@ -649,6 +660,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -865,6 +885,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.62" @@ -930,6 +959,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "combine" version = "4.6.7" @@ -1258,6 +1297,24 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "aes", + "block-padding", + "cbc", + "dbus", + "fastrand", + "hkdf", + "num", + "once_cell", + "sha2", + "zeroize", +] + [[package]] name = "der" version = "0.7.10" @@ -2624,6 +2681,16 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -2813,7 +2880,13 @@ version = "3.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" dependencies = [ + "byteorder", + "dbus-secret-service", "log", + "secret-service", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", "zeroize", ] @@ -3162,6 +3235,19 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "ntapi" version = "0.4.3" @@ -3171,6 +3257,20 @@ dependencies = [ "winapi", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -3232,6 +3332,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -4667,6 +4778,25 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "secret-service" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "hkdf", + "num", + "once_cell", + "rand 0.8.6", + "serde", + "sha2", + "zbus 4.4.0", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -5289,6 +5419,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "string_cache" version = "0.9.0" @@ -5737,7 +5873,7 @@ dependencies = [ "thiserror 2.0.18", "url", "windows 0.61.3", - "zbus", + "zbus 5.15.0", ] [[package]] @@ -7659,6 +7795,16 @@ dependencies = [ "rustix 1.1.4", ] +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "yoke" version = "0.8.2" @@ -7682,6 +7828,38 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-process", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "ordered-stream", + "rand 0.8.6", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros 4.4.0", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + [[package]] name = "zbus" version = "5.15.0" @@ -7712,9 +7890,22 @@ dependencies = [ "uuid", "windows-sys 0.61.2", "winnow 1.0.3", - "zbus_macros", - "zbus_names", - "zvariant", + "zbus_macros 5.15.0", + "zbus_names 4.3.2", + "zvariant 5.11.0", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils 2.1.0", ] [[package]] @@ -7727,9 +7918,20 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.117", - "zbus_names", - "zvariant", - "zvariant_utils", + "zbus_names 4.3.2", + "zvariant 5.11.0", + "zvariant_utils 3.3.1", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant 4.2.0", ] [[package]] @@ -7740,7 +7942,7 @@ checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" dependencies = [ "serde", "winnow 1.0.3", - "zvariant", + "zvariant 5.11.0", ] [[package]] @@ -7789,6 +7991,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "zerotrie" @@ -7873,6 +8089,19 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive 4.2.0", +] + [[package]] name = "zvariant" version = "5.11.0" @@ -7883,8 +8112,21 @@ dependencies = [ "enumflags2", "serde", "winnow 1.0.3", - "zvariant_derive", - "zvariant_utils", + "zvariant_derive 5.11.0", + "zvariant_utils 3.3.1", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils 2.1.0", ] [[package]] @@ -7897,7 +8139,18 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.117", - "zvariant_utils", + "zvariant_utils 3.3.1", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 58e88af3..dc2788a6 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -45,8 +45,12 @@ base64 = "0.22" hex = "0.4" getrandom = "0.2" # Secure credential storage in the OS keychain (macOS Keychain, Windows -# Credential Manager, Linux Secret Service via pure-Rust zbus — no libdbus). -keyring = "3" +# Credential Manager, Linux Secret Service via D-Bus). A backend feature MUST be +# enabled per platform — without one, keyring v3 silently uses an in-memory mock +# that never persists (writes return Ok but read back empty). The sync +# secret-service backend is used on Linux (blocking, so it can't dead-nest inside +# the async Tauri command runtime); crypto-rust encrypts the D-Bus session. +keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service", "crypto-rust"] } duckdb = { version = "1.3.2", features = ["bundled"] } tiberius = { version = "0.12.3", default-features = false, features = ["rustls", "tds73", "chrono"] } tokio-util = { version = "0.7.18", features = ["compat"] } diff --git a/src-tauri/src/db/query.rs b/src-tauri/src/db/query.rs index a5a1d4ca..80db4720 100644 --- a/src-tauri/src/db/query.rs +++ b/src-tauri/src/db/query.rs @@ -225,6 +225,60 @@ fn cell_to_json(row: &sqlx::postgres::PgRow, idx: usize) -> Value { } } + // Array types (varchar[]/text[]/int[]/…). Without this they fall through to the + // raw-bytes branch below, which reinterprets Postgres's *binary array wire format* + // (dimension/length header + element data) as lossy UTF-8 → garbage □ boxes. + // Decode into a Vec and return a JSON array; the frontend renders it {a,b}-style. + // sqlx validates the element PgType on each try_get, so only the matching arm + // returns — numeric arms are tried before the String arm so text[] isn't misread. + if type_name.ends_with("[]") { + macro_rules! try_arr { + ($t:ty) => { + if let Ok(v) = row.try_get::>>, _>(idx) { + return match v { + Some(a) => json!(a), + None => Value::Null, + }; + } + }; + } + // Types that JSON can't hold natively (Decimal/dates/Uuid) → stringify each + // element so it matches the scalar `to_string()` formatting above. + macro_rules! try_arr_str { + ($t:ty) => { + if let Ok(v) = row.try_get::>>, _>(idx) { + return match v { + Some(a) => json!(a + .into_iter() + .map(|x| x.map(|y| y.to_string())) + .collect::>()), + None => Value::Null, + }; + } + }; + } + try_arr!(bool); + try_arr!(i16); + try_arr!(i32); + try_arr!(i64); + try_arr!(f32); + try_arr!(f64); + try_arr_str!(Decimal); + try_arr_str!(DateTime); + try_arr_str!(NaiveDateTime); + try_arr_str!(NaiveDate); + try_arr_str!(NaiveTime); + try_arr_str!(Uuid); + // Text-like arrays (varchar/text/char/name), tried last. + if let Ok(v) = row.try_get::>>, _>(idx) { + return match v { + Some(a) => json!(a), + None => Value::Null, + }; + } + // Unknown element type (e.g. enum[]) — fall through to the raw branch. + } + // Use raw wire-protocol bytes for all remaining types (TEXT, VARCHAR, enums, domains…). // Skipping try_get::() avoids sqlx's runtime pg_catalog introspection for // custom/enum types, which would fire a `SELECT enumlabel FROM pg_enum WHERE …` query @@ -404,6 +458,28 @@ struct PgColumnMeta { } impl PgColumnMeta { + /// If this column is a Postgres array, return the quoted array type to cast a + /// literal to (e.g. `"pg_catalog"."_varchar"`), else None. Array types are + /// named with a leading underscore in pg_type; information_schema reports the + /// data_type as the literal "ARRAY" with the array name in udt_name. + fn array_cast_ref(&self) -> Result, String> { + let is_array = self.data_type.starts_with('_') || self.data_type.eq_ignore_ascii_case("ARRAY"); + if !is_array { + return Ok(None); + } + // Prefer udt_name when it's a real array name; fall back to data_type. + let arr_name = self + .udt_name + .as_deref() + .filter(|n| n.starts_with('_')) + .unwrap_or(self.data_type.as_str()); + if !arr_name.starts_with('_') { + return Ok(None); // couldn't resolve the concrete array type + } + let udt_schema = self.udt_schema.as_deref().unwrap_or("pg_catalog"); + Ok(Some(pg_cast_type_ref(udt_schema, arr_name)?)) + } + fn set_assignment_sql(&self, column: &str) -> Result { validate_ident(column)?; if self.data_type.eq_ignore_ascii_case("USER-DEFINED") { @@ -415,6 +491,14 @@ impl PgColumnMeta { let type_ref = pg_cast_type_ref(udt_schema, udt_name)?; return Ok(format!(r#""{column}" = $1::{type_ref}"#)); } + // Array columns. PostgreSQL names every array type with a leading + // underscore (_varchar, _int4, _text, …) — that's what pg_type.typname + // returns here; information_schema instead reports the literal "ARRAY". + // The editor sends a Postgres array literal ({"a","b"}); cast it to the + // real array type so PostgreSQL parses it instead of rejecting it as text. + if let Some(arr) = self.array_cast_ref()? { + return Ok(format!(r#""{column}" = $1::{arr}"#)); + } // json/jsonb bindings arrive as text strings; an explicit cast tells // PostgreSQL to interpret the parameter as json/jsonb instead of text. let norm = normalize_pg_type(&self.data_type); @@ -437,6 +521,10 @@ impl PgColumnMeta { let type_ref = pg_cast_type_ref(udt_schema, udt_name)?; return Ok(format!("${bind_idx}::{type_ref}")); } + // Array columns — cast the array-literal string to the real array type. + if let Some(arr) = self.array_cast_ref()? { + return Ok(format!("${bind_idx}::{arr}")); + } let norm = normalize_pg_type(&self.data_type); if norm == "json" || norm == "jsonb" { return Ok(format!("${bind_idx}::{norm}")); diff --git a/src-tauri/src/secrets.rs b/src-tauri/src/secrets.rs index d76c5bc8..ab1ca909 100644 --- a/src-tauri/src/secrets.rs +++ b/src-tauri/src/secrets.rs @@ -1,6 +1,17 @@ use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; use tauri::Manager; +// Session cache: once loaded (or written) it is authoritative for the process, +// so a value is always readable immediately after it is stored — even if the OS +// keychain read-back is flaky, unavailable, or (mis)configured as the mock store. +// Durable persistence still goes to the keychain/file below; this only guarantees +// read-after-write within a run. +static CACHE: OnceLock>>> = OnceLock::new(); +fn cache() -> &'static Mutex>> { + CACHE.get_or_init(|| Mutex::new(None)) +} + // All secrets (AI keys, provider OAuth tokens, Cloudflare tokens) live in a // single JSON blob stored in the OS keychain — macOS Keychain, Windows // Credential Manager, or Linux Secret Service. A legacy plaintext file @@ -31,37 +42,65 @@ fn write_keychain(map: &HashMap) -> Result<(), String> { entry.set_password(&json).map_err(|e| e.to_string()) } -pub(crate) fn read_all(app: &tauri::AppHandle) -> HashMap { - // 1) Preferred: the OS keychain (encrypted at rest). +/// Read the keychain back and confirm it holds exactly what we intended to store. +/// This is what makes a silently-non-persisting backend (e.g. keyring's mock +/// store) detectable: a write can "succeed" yet not round-trip, in which case we +/// must keep the plaintext file rather than delete it and lose the data. +fn keychain_holds(expected: &HashMap) -> bool { + keychain_entry() + .and_then(|e| e.get_password().ok()) + .map(|json| parse_map(&json) == *expected) + .unwrap_or(false) +} + +/// Load the durable store: keychain first (encrypted at rest), then the legacy +/// plaintext file, migrating the file into the keychain when that round-trips. +fn load_durable(app: &tauri::AppHandle) -> HashMap { if let Some(entry) = keychain_entry() { - match entry.get_password() { - Ok(json) => return parse_map(&json), - // No keychain entry yet → fall through to migrate any legacy file. - Err(keyring::Error::NoEntry) => {} - // Keychain unavailable this session → fall through to the file store. - Err(_) => {} + if let Ok(json) = entry.get_password() { + let map = parse_map(&json); + if !map.is_empty() { + return map; + } } } - // 2) Legacy plaintext file: read it, migrate into the keychain, remove the - // plaintext copy. Also the fallback store when the keychain is absent. + // Legacy plaintext file: the fallback store, and the pre-keychain format. let path = legacy_path(app); let map = std::fs::read_to_string(&path) .ok() .map(|s| parse_map(&s)) .unwrap_or_default(); - if !map.is_empty() && write_keychain(&map).is_ok() { + // Migrate into the keychain only if it verifiably persisted; otherwise leave + // the file exactly where it is. + if !map.is_empty() && write_keychain(&map).is_ok() && keychain_holds(&map) { let _ = std::fs::remove_file(&path); } map } +pub(crate) fn read_all(app: &tauri::AppHandle) -> HashMap { + let mut guard = cache().lock().unwrap_or_else(|e| e.into_inner()); + if let Some(map) = guard.as_ref() { + return map.clone(); + } + let map = load_durable(app); + *guard = Some(map.clone()); + map +} + pub(crate) fn write_all(app: &tauri::AppHandle, map: &HashMap) -> Result<(), String> { - // Prefer the keychain; on success make sure no plaintext copy lingers. - if write_keychain(map).is_ok() { + // Session cache is authoritative first, so reads right after this always see + // the new value regardless of what the durable backend does. + *cache().lock().unwrap_or_else(|e| e.into_inner()) = Some(map.clone()); + + // Prefer the keychain, but only trust it — and drop the plaintext copy — once + // a read-back proves the data actually persisted. + if write_keychain(map).is_ok() && keychain_holds(map) { let _ = std::fs::remove_file(legacy_path(app)); return Ok(()); } - // Keychain unavailable — fall back to the file so secrets aren't lost. + // Keychain missing, mock, or not round-tripping — persist to the file so + // secrets survive a restart. let path = legacy_path(app); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; diff --git a/src/app.css b/src/app.css index ff915d6c..a504eb9f 100644 --- a/src/app.css +++ b/src/app.css @@ -10,6 +10,12 @@ /* Non-theme tokens only — color palettes live on html[data-theme] in app-themes.css */ :root { + /* Tell the engine to render native controls (the { if (e.key === 'Enter') { e.preventDefault(); addItem() } }} + /> + {/if} +
+ + +
+ + {/each} + + {/if} + + + +
+ + {#if items.length > 0} + + {/if} +
+ + +
+
+ + +{/if} diff --git a/src/lib/components/CloudflareLogin.svelte b/src/lib/components/CloudflareLogin.svelte index 61b9b920..a452dbb3 100644 --- a/src/lib/components/CloudflareLogin.svelte +++ b/src/lib/components/CloudflareLogin.svelte @@ -6,6 +6,7 @@ import AlertTriangle from '@lucide/svelte/icons/alert-triangle' import ChevronDown from '@lucide/svelte/icons/chevron-down' import RefreshCw from '@lucide/svelte/icons/refresh-cw' + import ArrowRight from '@lucide/svelte/icons/arrow-right' import Search from '@lucide/svelte/icons/search' import DbIcon from './DbIcon.svelte' import { cfStartOAuth, cfOAuthStatus, cfLogout } from '$lib/cloudflare.js' @@ -43,6 +44,21 @@ : databases, ) + /** Turn a raw backend error into a calm title + one-line explanation. */ + function friendlyError(msg) { + const m = String(msg ?? '') + if (/not signed in|no.*token|unauthor/i.test(m)) + return { title: 'Session expired', detail: 'Your Cloudflare sign-in is no longer valid. Sign in again to continue.' } + if (/timed out/i.test(m)) + return { title: 'Authorization timed out', detail: 'The browser sign-in took too long. Start again when you are ready.' } + if (/cancel|denied/i.test(m)) + return { title: 'Authorization not completed', detail: 'The browser closed before authorizing. Try again to connect.' } + if (/port .*in use|bind any callback/i.test(m)) + return { title: 'Callback port in use', detail: m } + return { title: 'Something went wrong', detail: m || 'Please try again.' } + } + const shownError = $derived(friendlyError(errorMsg)) + onMount(async () => { const status = await cfOAuthStatus() if (status.connected) { @@ -131,39 +147,36 @@
{#if phase === 'idle'} - -
-
-
- -
-
-

Connect with Cloudflare

-

Authorize Stroke to browse your D1 databases.

-
-
-
- -

Same OAuth flow as the Wrangler CLI · secure PKCE.

-
+ +
+ +

Opens your browser to authorize · same PKCE flow as Wrangler

{:else if phase === 'authorizing'} -
-
- +
+
+
+ + +
+
+

Waiting for Cloudflare…

+

+ Finish authorizing in the browser tab, then come back here. +

+
-
-

Waiting for Cloudflare…

-

Authorize Stroke in the browser tab, then return here.

+
+
@@ -284,17 +297,45 @@ {/if} {:else if phase === 'error'} -
-
- - {errorMsg || 'Authorization failed.'} +
+
+
+ +
+
+

{shownError.title}

+

{shownError.detail}

+
+ > + Try again +
{/if}
+ + diff --git a/src/lib/components/ConnectionModal.svelte b/src/lib/components/ConnectionModal.svelte index 3f07e673..35ae9a74 100644 --- a/src/lib/components/ConnectionModal.svelte +++ b/src/lib/components/ConnectionModal.svelte @@ -98,7 +98,15 @@ // Provider (sign-in) ids are surfaced as cards on their own tab, so keep them // out of the manual Type dropdown. const PROVIDER_IDS = ['neon', 'supabase', 'planetscale', 'prisma'] - const manualDriverItems = driverItems.filter((d) => !PROVIDER_IDS.includes(d.value)) + // Cloudflare D1 signs in like a hosting provider (OAuth), so it belongs on the + // Provider tab too — but it drives the CloudflareLogin flow, not ProviderConnect. + const PROVIDER_CARDS = [ + ...PROVIDERS, + { id: 'd1', name: 'Cloudflare D1', engine: 'sqlite', blurb: 'Edge SQLite — sign in with Cloudflare' }, + ] + // Every sign-in provider (incl. D1) is reached from the Provider tab, so keep + // them all out of the manual Type dropdown. + const manualDriverItems = driverItems.filter((d) => ![...PROVIDER_IDS, 'd1'].includes(d.value)) // Subtle per-engine icon tint (color-500/600), theme-aware via Tailwind tokens. const ENGINE_TINT = { @@ -203,6 +211,8 @@ const activeDriver = $derived(ALL_DRIVERS.find(d => d.id === dbType) ?? ALL_DRIVERS[0]) const isProvider = $derived(PROVIDER_IDS.includes(dbType)) + // D1 shares the Provider tab (via CloudflareLogin) but is not a ProviderConnect id. + const isProviderTab = $derived(isProvider || dbType === 'd1') // Engines that expose the "Connection string | Manual fields" toggle. const URI_TOGGLE_ENGINES = ['postgres', 'cockroachdb', 'mysql', 'mariadb'] @@ -222,7 +232,7 @@ function setEntryMode(mode) { if (entryMode === mode) return entryMode = mode - if (mode === 'manual' && isProvider) switchDriver('postgres') + if (mode === 'manual' && isProviderTab) switchDriver('postgres') } function sshPayload() { @@ -281,7 +291,7 @@ sshEnabled = false; sshHost = ''; sshPort = '22'; sshUsername = ''; sshKeyPath = '' readOnly = false } - entryMode = PROVIDER_IDS.includes(dbType) ? 'provider' : 'manual' + entryMode = (PROVIDER_IDS.includes(dbType) || dbType === 'd1') ? 'provider' : 'manual' fieldMode = 'fields' advancedOpen = false flashedFields = new Set() @@ -322,6 +332,31 @@ }) } + /** + * Cloudflare D1 picked from the Provider tab: fill the D1 fields and connect + * immediately, mirroring the one-click flow of the other hosting providers. + * @param {{accountId: string, databaseId: string, databaseName: string, token: string}} info + */ + async function connectD1Selection(info) { + error = '' + accountId = info.accountId + databaseId = info.databaseId + apiToken = info.token + if (!name || name === 'Cloudflare D1') name = info.databaseName + // Reuse an existing saved entry for this exact D1 database instead of piling + // up duplicates — connectWith upserts it. + const existing = saved.find((s) => s.type === 'd1' && s.databaseId === info.databaseId) + await connectWith({ + id: existing?.id ?? newConnectionId(), + type: 'd1', + name: info.databaseName, + accountId: info.accountId, + databaseId: info.databaseId, + apiToken: info.token, + readOnly: readOnly || undefined, + }) + } + function switchDriver(id) { dbType = id if (id === 'postgres') port = '5432' @@ -1031,42 +1066,6 @@ class={cn(inp, 'font-mono text-[11px]')} />
- - {:else if dbType === 'd1'} - - { - accountId = info.accountId - databaseId = info.databaseId - apiToken = info.token - if (!name || name === 'Cloudflare D1') name = info.databaseName - }} - ondisconnect={() => { accountId = ''; databaseId = ''; apiToken = '' }} - /> - -
- - ↓ Manual setup - - -
-
-
- - -
-
- - -
-
-
- - -
-
-
- {:else if dbType === 'clickhouse'} @@ -1220,7 +1219,7 @@
- {#each PROVIDERS as p, i (p.id)} + {#each PROVIDER_CARDS as p, i (p.id)} {@const active = dbType === p.id}
+ {:else if dbType === 'd1'} +
+ {#key dbType} + { accountId = ''; databaseId = ''; apiToken = '' }} + /> + {/key} + +
+ + + Enter account & token manually + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
{@render advancedSection()}
{/if} diff --git a/src/lib/components/DataTable.svelte b/src/lib/components/DataTable.svelte index 130a7884..982621f8 100644 --- a/src/lib/components/DataTable.svelte +++ b/src/lib/components/DataTable.svelte @@ -5,7 +5,7 @@ import { zoomState } from '$lib/stores/canvas-zoom.svelte.js' // Zoom is driven through the app-level settings so the canvas scales together // with the rest of the UI (applySettings mirrors the app zoom into zoomState). - import { increaseZoom, decreaseZoom, resetZoom, appPreviewDml } from '$lib/stores/settings.js' + import { increaseZoom, decreaseZoom, resetZoom, appPreviewDml, appTableStyle, TABLE_STYLES, normalizeTableStyle } from '$lib/stores/settings.js' import { toast } from "$lib/components/ui/sonner/toast.svelte.js"; import { Checkbox } from "$lib/components/ui/checkbox/index.js"; import * as ContextMenu from "$lib/components/ui/context-menu/index.js"; @@ -98,6 +98,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; import Sparkles from "@lucide/svelte/icons/sparkles"; import MediaLightbox from "./MediaLightbox.svelte"; import RowExpandViewer from "./RowExpandViewer.svelte"; + import ArrayCellEditor from "./ArrayCellEditor.svelte"; import FkSubviewPanel from "./FkSubviewPanel.svelte"; // JsonCellLightbox (Monaco-based) is imported lazily at its render site below. import CellQuickLook from "./CellQuickLook.svelte"; @@ -380,6 +381,14 @@ import FilterX from "@lucide/svelte/icons/filter-x"; let contextRowIdx = $state(0); let contextColIdx = $state(0); let contextMenuOpen = $state(false); + + // Array cell editor (Prisma-style add/remove for SQL array columns). + let arrayEditorOpen = $state(false); + let arrayEditorRow = $state(0); + let arrayEditorCol = $state(0); + let arrayEditorColName = $state(""); + let arrayEditorType = $state(""); + let arrayEditorValue = $state(/** @type {any[]} */ ([])); let pendingContextMenu = $state(false); /** Block item activation from the right-click pointerup that opened the menu */ let suppressMenuSelect = $state(false); @@ -624,6 +633,21 @@ import FilterX from "@lucide/svelte/icons/filter-x"; ); // Truncated cells only hold a preview — filtering on it would build wrong SQL. const menuCellOversize = $derived(!!oversizeCellInfo(rows[contextRowIdx]?.[contextColIdx])); + // SQL array column? (value already decoded to a JS array, or type ends with []). + const menuColType = $derived( + String(columns[contextColIdx]?.dataType ?? columns[contextColIdx]?.data_type ?? _colCache[contextColIdx]?.colType ?? ""), + ); + // The dedicated array editor writes a Postgres array literal ({a,b}) cast to the + // real array type — that's native to PostgreSQL & CockroachDB (Neon/Supabase/ + // Prisma all speak the pg wire protocol, so they route through the same path). + // Other engines either have no native arrays (MySQL/SQLite/MSSQL) or use a + // different literal (ClickHouse/DuckDB [..]), so restrict the editor to pg-family + // to avoid producing a write the backend can't apply. + const isPgArrayDialect = $derived(dialect === "postgres" || dialect === "cockroachdb"); + const menuCellIsArray = $derived( + isPgArrayDialect && + (Array.isArray(rows[contextRowIdx]?.[contextColIdx]) || /\[\]\s*$/.test(menuColType)), + ); // Extension-provided transforms applicable to the right-clicked cell. const menuTransforms = $derived.by(() => { void $pluginState; @@ -652,6 +676,9 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // Oversize sentinels carry a preview instead of the real (multi-MB) // value — render the truncation marker + head, not the sentinel wrapper. const over = oversizeCellInfo(value); + // JSON/JSONB objects and arrays render as JSON here. SQL *array columns* get + // the pgAdmin {a,b} form instead, but that decision needs the column type, so + // it lives in drawCell (arrayDisplay) — a jsonb array must stay ["a","b"]. const s = over ? oversizeCellText(over) : JSON.stringify(value); _formatCache.set(value, s); return s; @@ -659,6 +686,41 @@ import FilterX from "@lucide/svelte/icons/filter-x"; return String(value); } + // Render a JS array as a Postgres array literal for display: {a,b}, {} for + // empty, NULL for null elements. Elements are quoted only when they contain a + // delimiter/quote/brace/whitespace or would be ambiguous — matching pgAdmin. + function pgArrayElem(el) { + if (el === null || el === undefined) return "NULL"; + // Nested arrays (multi-dim) recurse; objects (e.g. json[]) fall back to JSON. + if (Array.isArray(el)) return pgArrayText(el); + if (typeof el === "object") return JSON.stringify(el); + const s = String(el); + if (s === "" || /[",{}\\\s]/.test(s) || /^null$/i.test(s)) { + return '"' + s.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"'; + } + return s; + } + function pgArrayText(arr) { + return "{" + arr.map(pgArrayElem).join(",") + "}"; + } + // Cached pgAdmin-style display for SQL *array columns* only (drawCell passes the + // value after confirming the column type ends with []). Cached per value object + // so the scroll hot path never rebuilds the string. jsonb arrays never reach + // this — they render as ["a","b"] via formatCell. + /** @type {WeakMap} */ + const _arrayDisplayCache = new WeakMap(); + function arrayDisplay(arr) { + const hit = _arrayDisplayCache.get(arr); + if (hit !== undefined) return hit; + const s = pgArrayText(arr); + _arrayDisplayCache.set(arr, s); + return s; + } + /** True when a column's SQL type is an array (ends with []). */ + function isSqlArrayType(colType) { + return /\[\]\s*$/.test(colType ?? ""); + } + /** Truncated version for DOM rendering — keeps long values out of the render tree */ function displayCell(value) { const s = formatCell(value); @@ -673,6 +735,12 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // cell text, badges, tints, and the header annotator strip. $effect(() => { void $pluginState; void _colStats; scheduleDraw(); }); + // Resolved canvas-grid style preset (Settings → Appearance). Read once per frame + // by draw() and passed into the row context, so it never adds per-cell reactivity. + const _tableStyle = $derived(TABLE_STYLES[normalizeTableStyle($appTableStyle)]); + // Repaint the grid the moment the user switches preset. + $effect(() => { void $appTableStyle; scheduleDraw(); }); + // ── Search-match highlighting ────────────────────────────────────────────── // The toolbar search filters rows server-side (ILIKE, case-insensitive); // this paints where each match falls inside the visible cell text. Matching @@ -1549,6 +1617,30 @@ import FilterX from "@lucide/svelte/icons/filter-x"; futureEdits = []; } + /** Open the dedicated array editor for a cell (from the context menu). */ + function openArrayEditor(rowIdx, colIdx) { + const col = columns[colIdx]; + if (!col) return; + const v = effectiveCellValue(rowIdx, colIdx); + arrayEditorRow = rowIdx; + arrayEditorCol = colIdx; + arrayEditorColName = col.name ?? "array"; + arrayEditorType = String(col.dataType ?? col.data_type ?? _colCache[colIdx]?.colType ?? "").replace(/\[\]\s*$/, ""); + arrayEditorValue = Array.isArray(v) ? v : []; + arrayEditorOpen = true; + } + + /** Save the edited array — stage a Postgres array literal (backend casts it). */ + function commitArrayEditor(next) { + const rowIdx = arrayEditorRow, colIdx = arrayEditorCol; + if (!canEditColumn(colIdx)) return; + const prevValue = effectiveCellValue(rowIdx, colIdx); + const literal = pgArrayText(next); // {a,b} — quoting/escaping handled + stageEdit(rowIdx, colIdx, literal); + pastEdits = [...pastEdits.slice(-49), { rowIdx, colIdx, oldValue: prevValue, newValue: literal }]; + futureEdits = []; + } + /** Run an extension transform on a cell and copy the result to the clipboard. */ async function runCellTransform(rowIdx, colIdx, transform) { const value = effectiveCellValue(rowIdx, colIdx); @@ -1834,9 +1926,11 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // Width adapts to the longest label (7px/char estimate + padding), clamped 110–180px const VIRTUAL_COL_W = $derived.by(() => { if (virtualColWidthOverride !== null) return Math.round(virtualColWidthOverride * canvasZoom) - if (!virtualRelCols.length) return Math.round(300 * canvasZoom) + if (!virtualRelCols.length) return Math.round(200 * canvasZoom) + // Fit the badge with comfortable side gaps instead of a fixed ~300px slab, so + // the centered pill reads as intentional rather than floating in dead space. const maxChars = Math.max(...virtualRelCols.map(v => v.label.length)) - const base = Math.min(380, Math.max(300, maxChars * 10 + 60)) + const base = Math.min(260, Math.max(150, maxChars * 8 + 44)) return Math.round(base * canvasZoom) }) const virtualRelCols = $derived.by(() => { @@ -2407,10 +2501,18 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // scroll event to cover momentum/inertia, then stops to save GPU time. let _scrollLoopId = 0 let _scrollLoopDeadline = 0 + // Last position the loop actually painted — lets it skip identical frames during + // the momentum tail / step scrolling instead of re-running a full redraw for a + // frame where nothing moved. Content changes (hover/edits) go through + // scheduleDraw(), not this loop, so skipping unchanged-position frames is safe. + let _loopLastTop = -1 + let _loopLastLeft = -1 function startScrollLoop() { _scrollLoopDeadline = performance.now() + 200 if (_scrollLoopId) return + _loopLastTop = -1 + _loopLastLeft = -1 function loop() { const el = tableContainer if (!el || !_ctx || _fatalError || performance.now() > _scrollLoopDeadline) { @@ -2421,14 +2523,20 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // integer left edge, so drawing content at a fractional scrollLeft puts text // and gridlines on sub-pixel x — WebKit then re-antialiases them every frame, // which reads as horizontal "vibration". Integer offsets render stably. - _scrollTop = Math.round(el.scrollTop) - _scrollLeft = Math.round(el.scrollLeft) - try { - draw() - } catch (err) { - reportFatal(err) - _scrollLoopId = 0 - return + const st = Math.round(el.scrollTop) + const sl = Math.round(el.scrollLeft) + if (st !== _loopLastTop || sl !== _loopLastLeft) { + _scrollTop = st + _scrollLeft = sl + _loopLastTop = st + _loopLastLeft = sl + try { + draw() + } catch (err) { + reportFatal(err) + _scrollLoopId = 0 + return + } } _scrollLoopId = requestAnimationFrame(loop) } @@ -2461,6 +2569,9 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // ping-pong between separate effects. /** @type {CanvasRenderingContext2D | null} */ let _ctx = null + // Reused scratch buffer for per-frame vertical grid separators (see draw()) — a + // module-lifetime array so the scroll hot path does zero allocation for it. + const _vSepsBuf = /** @type {number[]} */ ([]) /** @type {ReturnType | null} */ let _readColor = null /** Canvas font strings measured from the DOM so they exactly match the app's @@ -2871,9 +2982,46 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // Frame-constant draw context — built ONCE per frame and shared by every // visible row, instead of a fresh ~20-field literal per row (that churned // thousands of short-lived objects/sec during scroll → GC jank). + // Grid style preset + its dot size (integer, DPR-agnostic — canvas is already + // scaled by canvasZoom), resolved once per frame. + const tableStyle = _tableStyle + const dotSize = Math.max(2, Math.round(2 * canvasZoom)) + + // Vertical separator x-positions are identical for every row (they depend only + // on columns + scroll, not the row), so collect them ONCE per frame here rather + // than re-deriving them inside every drawBodyRow. Keeps the per-row grid pass to + // a single loop over this array — flat regardless of how many million rows exist. + // Reuse one buffer across frames so the scroll hot path allocates nothing here. + const vSeps = _vSepsBuf + vSeps.length = 0 + for (const col of geom.cols) { + if (col.pinned) continue + const dx = col.contentX - _scrollLeft + if (dx >= W) break + if (dx + col.w <= 0) continue + const ex = dx + col.w - 0.5 + if (ex <= frozenW) continue + vSeps.push(ex) + } + for (let vi = 0; vi < virtualRelCols.length; vi++) { + const ex = geom.totalWidth + vexprTotalW + vi * VIRTUAL_COL_W - _scrollLeft + VIRTUAL_COL_W - 0.5 + if (ex <= frozenW || ex >= W) continue + vSeps.push(ex) + } + for (const col of geom.cols) { + if (!col.pinned) continue + const ex = colDrawnX(col, geom, _scrollLeft) + col.w - 0.5 + if (ex <= 0 || ex >= W) continue + vSeps.push(ex) + } + if (gutterWidth > 0) { + const gex = (gutterWidth - _scrollLeft) - 0.5 + if (gex > 0 && gex < W) vSeps.push(gex) + } + const bodyC = { - cFg, cText, cMuted, cGrid, cMutedBg, cRing, cAccent, cPanel, usedW, navName, - AMBER, BLUE_FG, RED, cPrimary, frozenW, + cFg, cText, cMuted, cGrid, cBorder, cMutedBg, cRing, cAccent, cPanel, usedW, navName, + AMBER, BLUE_FG, RED, cPrimary, frozenW, tableStyle, dotSize, vSeps, rangeColNames, rangeFirstCol, rangeLastCol, rangeR0, rangeR1, } @@ -2917,6 +3065,11 @@ import FilterX from "@lucide/svelte/icons/filter-x"; } else if (hoveredRow === idx) { ctx.fillStyle = withAlpha(c.cMutedBg, 0.18) ctx.fillRect(0, ry, c.usedW, rh) + } else if (c.tableStyle.zebra && (idx & 1)) { + // Zebra striping — a soft tint on odd rows. Below every interactive state + // above so selection/hover/focus always win; O(1), no per-row allocation. + ctx.fillStyle = withAlpha(c.cMutedBg, 0.07) + ctx.fillRect(0, ry, c.usedW, rh) } // Non-pinned cells. geom.cols is ordered by ascending contentX, so once a @@ -2998,17 +3151,19 @@ import FilterX from "@lucide/svelte/icons/filter-x"; const isVHov = hoveredRow === idx && hoveredColName === `__vrel__${vi}` if (!_fonts) return - // Badge: compact tag style — no border at rest, border on hover/active + // Badge: compact tag style — no border at rest, border on hover/active. const badgeFontPx = Math.max(10, _fonts.cellPx - 1) - const bPadX = 8 - const bH = Math.round(badgeFontPx * 1.55) - const bR = 3 + const bPadX = 10 + const bH = Math.round(badgeFontPx * 1.7) + const bR = Math.round(bH / 2) // pill — fully rounded, reads as a chip ctx.font = `500 ${badgeFontPx}px ${_fonts.family}` - const maxLabelW = VIRTUAL_COL_W - 24 + // Consistent side gutters so the pill is centered with breathing room. + const gutter = Math.round(14 * canvasZoom) + const maxLabelW = VIRTUAL_COL_W - gutter * 2 - bPadX * 2 const labelTxt = truncText(ctx, vc.label, maxLabelW) const textW = textWidth(ctx, labelTxt) - const bW = Math.min(textW + bPadX * 2, VIRTUAL_COL_W - 16) + const bW = Math.min(textW + bPadX * 2, VIRTUAL_COL_W - gutter * 2) const bX = cellX + (VIRTUAL_COL_W - bW) / 2 const bY = ry + (rh - bH) / 2 @@ -3016,16 +3171,16 @@ import FilterX from "@lucide/svelte/icons/filter-x"; ctx.fillStyle = isActive ? withAlpha(c.cPrimary, 0.15) - : isVHov ? withAlpha(c.cMutedBg, 0.55) : withAlpha(c.cMutedBg, 0.2) + : isVHov ? withAlpha(c.cMutedBg, 0.6) : withAlpha(c.cMutedBg, 0.3) roundRect(ctx, bX, bY, bW, bH, bR); ctx.fill() if (isActive || isVHov) { - ctx.strokeStyle = isActive ? withAlpha(c.cPrimary, 0.45) : withAlpha(c.cMuted, 0.22) + ctx.strokeStyle = isActive ? withAlpha(c.cPrimary, 0.45) : withAlpha(c.cMuted, 0.25) ctx.lineWidth = 1 roundRect(ctx, bX + 0.5, bY + 0.5, bW - 1, bH - 1, bR); ctx.stroke() } - ctx.fillStyle = isActive ? c.cPrimary : withAlpha(c.cFg, 0.6) + ctx.fillStyle = isActive ? c.cPrimary : withAlpha(c.cFg, 0.72) ctx.textBaseline = 'middle'; ctx.textAlign = 'center' ctx.fillText(labelTxt, bX + bW / 2, ry + rh / 2 + 0.5) } @@ -3036,42 +3191,37 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // per cell. This collapses ~(cols+3) draw-call flushes per row down to one, // the single biggest scroll-perf win alongside O(1) text measurement. const vw = _viewportWidth - const frozenW = c.frozenW ?? gutterWidth - ctx.strokeStyle = c.cGrid - ctx.lineWidth = 1 - ctx.beginPath() - // Non-pinned column separators (skip ones hidden behind the frozen region). - // Same ascending-contentX ordering → break past the right edge. - for (const col of geom.cols) { - if (col.pinned) continue - const dx = col.contentX - _scrollLeft - if (dx >= vw) break - if (dx + col.w <= 0) continue - const ex = dx + col.w - 0.5 - if (ex <= frozenW) continue - ctx.moveTo(ex, ry); ctx.lineTo(ex, ry + rh) - } - // Virtual relationship column separators. - for (let vi = 0; vi < virtualRelCols.length; vi++) { - const ex = geom.totalWidth + vexprTotalW + vi * VIRTUAL_COL_W - _scrollLeft + VIRTUAL_COL_W - 0.5 - if (ex <= frozenW || ex >= vw) continue - ctx.moveTo(ex, ry); ctx.lineTo(ex, ry + rh) - } - // Pinned column separators (frozen, drawn on top of their fills). - for (const col of geom.cols) { - if (!col.pinned) continue - const ex = colDrawnX(col, geom, _scrollLeft) + col.w - 0.5 - if (ex <= 0 || ex >= vw) continue - ctx.moveTo(ex, ry); ctx.lineTo(ex, ry + rh) - } - // Gutter separator. - if (gutterWidth > 0) { - const gex = (gutterWidth - _scrollLeft) - 0.5 - if (gex > 0 && gex < vw) { ctx.moveTo(gex, ry); ctx.lineTo(gex, ry + rh) } + // Vertical separators were collected once for the frame; the row only chooses + // how to render them per the active grid-style preset. All branches stay a + // single batched path/fill, so this is O(visible cols) no matter the row count. + const ts = c.tableStyle + const seps = c.vSeps + // "Bordered" preset draws with the stronger border token for a high-contrast grid. + const gridColor = ts.strong ? c.cBorder : c.cGrid + + if (ts.dots) { + // "Connection dot" grid — a small square at each cell join (column separator + // × the row's bottom edge) instead of full lines. One batched fill per row. + const ds = c.dotSize + const dy = ry + rh - ds + const half = (ds / 2) | 0 + ctx.fillStyle = gridColor + ctx.beginPath() + ctx.rect(0, dy, ds, ds) // left edge join + for (let k = 0; k < seps.length; k++) ctx.rect((seps[k] - half) | 0, dy, ds, ds) + ctx.fill() + } else { + ctx.strokeStyle = gridColor + ctx.lineWidth = 1 + if (ts.dash) ctx.setLineDash(ts.dash) + ctx.beginPath() + if (ts.cols) { + for (let k = 0; k < seps.length; k++) { ctx.moveTo(seps[k], ry); ctx.lineTo(seps[k], ry + rh) } + } + if (ts.rows) { ctx.moveTo(0, ry + rh - 0.5); ctx.lineTo(vw, ry + rh - 0.5) } + ctx.stroke() + if (ts.dash) ctx.setLineDash([]) // reset so other strokes stay solid } - // Bottom row line. - ctx.moveTo(0, ry + rh - 0.5); ctx.lineTo(vw, ry + rh - 0.5) - ctx.stroke() } /** @param {CanvasRenderingContext2D} ctx */ @@ -3156,9 +3306,13 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // Cell text — directive display wins; masked cells reveal on hover. const revealed = dir?.mask && isHover + // SQL array columns render pgAdmin-style ({a,b}); jsonb arrays stay JSON. + const isArrayCol = Array.isArray(value) && isSqlArrayType(cached?.colType) const text = dir ? String(revealed ? (dir.reveal ?? dir.display) : (dir.display ?? displayCell(value))) - : displayCell(value) + : isArrayCol + ? arrayDisplay(value) + : displayCell(value) // Text color — directive link/fg may override (but never over a stronger // dirty/fk/focused state highlight). @@ -4823,6 +4977,12 @@ import FilterX from "@lucide/svelte/icons/filter-x"; Enter {/if} + {#if menuCellIsArray && menuEditable && !readonly} + runMenuAction(() => openArrayEditor(contextRowIdx, contextColIdx))}> + + Edit array… + + {/if} runMenuAction(() => copyCellValue(contextRowIdx, contextColIdx))}> Copy @@ -5007,6 +5167,14 @@ import FilterX from "@lucide/svelte/icons/filter-x"; {/await} {/if} + + { try { const status = await providerOAuthStatus(provider) @@ -209,23 +230,34 @@ {/if} {:else if phase === 'authorizing'} -
+
-
- + +
+ +
-

Waiting for {meta?.name}…

-

Authorize Stroke in the browser tab, then return here. Times out in 5 min.

+

Waiting for {meta?.name}…

+

+ Finish authorizing in the browser tab, then come back here. +

- + +
+ +
+
+ Times out in 5 min + +
{:else if phase === 'fetching'} @@ -354,16 +386,44 @@ {/if} {:else if phase === 'error'} -
-
- - {errorMsg || 'Something went wrong.'} +
+
+
+ +
+
+

{shownError.title}

+

{shownError.detail}

+
+ > + Try again +
{/if}
+ + diff --git a/src/lib/components/ReportIssueDialog.svelte b/src/lib/components/ReportIssueDialog.svelte index f39bd20e..d0feea0b 100644 --- a/src/lib/components/ReportIssueDialog.svelte +++ b/src/lib/components/ReportIssueDialog.svelte @@ -126,14 +126,14 @@ type="button" onclick={() => (issueType = t.id)} class={cn( - "inline-flex h-[28px] flex-1 items-center justify-center gap-1.5 rounded-md border text-[11px] font-medium transition-colors", + "inline-flex h-[28px] min-w-0 flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md border px-1 text-[11px] font-medium transition-colors", issueType === t.id ? "border-border/50 bg-muted/60 text-foreground" : "border-border/20 text-muted-foreground/35 hover:border-border/30 hover:bg-muted/20 hover:text-muted-foreground/70", )} > - {t.label} + {t.label} {/each}
@@ -182,12 +182,12 @@
-
+
{/if} + {#if show('Table style', 'Grid style for the data table')} +
+
+

Table style

+

Grid style for the data table — lines, dotted, or connection dots.

+
+ { if (v) setTableStyle(/** @type {import('$lib/stores/settings.js').TableStyleId} */ (v)); }}> + + + + {TABLE_STYLES[settings.tableStyle]?.label ?? "Lines"} + + + + {#each tableStyleEntries as [id, preset] (id)} + + {#snippet children()} + + + + {preset.label} + {preset.description} + + + {/snippet} + + {/each} + + +
+ {/if} + {@render secLabel('Display')} {#if show('Zoom', 'Scale the whole interface')}
diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte index 12d8c71f..16172f5e 100644 --- a/src/lib/components/Sidebar.svelte +++ b/src/lib/components/Sidebar.svelte @@ -690,7 +690,7 @@
{ diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index f26f1cc6..1593883e 100644 --- a/src/lib/components/StudioShell.svelte +++ b/src/lib/components/StudioShell.svelte @@ -310,6 +310,10 @@ /** @type {StudioTab[]} */ let tabs = $state([]) + // Recently-closed tabs (most-recent last), for Reopen Closed Tab (Ctrl/⌘+Shift+T). + // Stores lightweight descriptors, not live tab objects; capped so it can't grow. + let closedTabStack = $state(/** @type {any[]} */ ([])) + const CLOSED_TAB_STACK_MAX = 20 let activeTabId = $state(/** @type {string | null} */ (null)) // ── Split-pane / editor-group layout ───────────────────────────────────── @@ -1283,11 +1287,44 @@ let rowSearch = $state('') toggleStatusBar() }) + // Reopen the most recently closed tab (browser-style). createHotkey('Mod+Shift+T', (e) => { + if (!connection) return + e.preventDefault() + reopenLastClosedTab() + }) + + // Tab-bar visibility toggle moved here so Mod+Shift+T can reopen closed tabs. + createHotkey('Alt+Shift+T', (e) => { e.preventDefault() toggleTabBar() }) + // Disconnect the current connection (opens the confirm dialog). + createHotkey('Mod+Alt+D', (e) => { + if (!connection) return + e.preventDefault() + showDisconnectDialog = true + }) + + // Open the keyboard-shortcuts reference (Ctrl/⌘+/ — same key as Mod+? without shift). + createHotkey('Mod+/', (e) => { + if (commandOpen || showConnectionModal || showSettingsModal || showShortcutsModal) return + e.preventDefault() + showShortcutsModal = true + }) + + // Jump straight to tab N (Ctrl/⌘+1..8); 9 always jumps to the last tab — + // the same convention as browsers and editors. + for (let n = 1; n <= 9; n++) { + createHotkey(`Mod+${n}`, (e) => { + if (!connection || tabs.length === 0) return + e.preventDefault() + const idx = n === 9 ? tabs.length - 1 : Math.min(n - 1, tabs.length - 1) + void activateTab(tabs[idx].id) + }) + } + createHotkey('Mod+Shift+L', (e) => { e.preventDefault() openLogsTab() @@ -1977,6 +2014,7 @@ let rowSearch = $state('') const key = tabTableKey(closing) if (key) clearPendingChanges(key) } + rememberClosedTab(closing) const nextTabs = tabs.filter((t) => t.id !== id) if (nextTabs.length === 0) { tabs = [createWelcomeTab()] @@ -1991,6 +2029,28 @@ let rowSearch = $state('') } } + /** Push a closed tab onto the reopen stack (welcome tabs aren't worth restoring). */ + function rememberClosedTab(tab) { + if (!tab || tab.kind === 'welcome') return + // Snapshot with a shallow state clone so later edits to the live tree can't + // mutate what we'll restore. `id`/`pinned` are dropped — reopen mints fresh. + const { id: _id, pinned: _pinned, ...rest } = tab + const snapshot = { ...rest, state: tab.state ? { ...tab.state } : tab.state } + closedTabStack = [...closedTabStack, snapshot].slice(-CLOSED_TAB_STACK_MAX) + } + + /** Ctrl/⌘+Shift+T — reopen the most recently closed tab. */ + function reopenLastClosedTab() { + const entry = closedTabStack[closedTabStack.length - 1] + if (!entry) return + closedTabStack = closedTabStack.slice(0, -1) + saveActiveTabState() + dropWelcomeTabs() + const tab = { ...entry, id: crypto.randomUUID(), pinned: false, state: entry.state ? { ...entry.state } : entry.state } + tabs = [...tabs, tab] + void activateTab(tab.id) + } + /** @param {string} id — keep this tab (and pinned tabs), close everything else */ async function closeOtherTabs(id) { const keep = tabs.find((t) => t.id === id) diff --git a/src/lib/components/UpdateDialog.svelte b/src/lib/components/UpdateDialog.svelte index 07811727..61c5d9bc 100644 --- a/src/lib/components/UpdateDialog.svelte +++ b/src/lib/components/UpdateDialog.svelte @@ -178,13 +178,16 @@ await invoke('restart_app') } - /** Open the online changelog in the user's browser. */ + // Tagged so web analytics can attribute changelog views to the desktop app. + const CHANGELOG_URL = 'https://stroke.click/changelog?utm_source=stroke-app&utm_medium=update-dialog&utm_campaign=changelog' + + /** Open the online changelog in the user's browser (never the in-app tab). */ async function openChangelog() { try { const { openUrl } = await import('@tauri-apps/plugin-opener') - await openUrl('https://stroke.click/changelog') + await openUrl(CHANGELOG_URL) } catch { - window.open('https://stroke.click/changelog', '_blank', 'noopener,noreferrer') + window.open(CHANGELOG_URL, '_blank', 'noopener,noreferrer') } } @@ -325,17 +328,18 @@

{errorMsg}

{:else if status === 'up-to-date' || checking} -

+

{checking ? 'Checking GitHub for a newer release…' : "You're on the latest version."}

{#if !checking} {/if} {/if} diff --git a/src/lib/stores/settings.js b/src/lib/stores/settings.js index 054f3b0d..89eb4066 100644 --- a/src/lib/stores/settings.js +++ b/src/lib/stores/settings.js @@ -14,7 +14,7 @@ const STORAGE_KEY = 'stroke:settings' /** @typedef {'geist' | 'serif' | 'apple'} FontId */ /** @typedef {'regular' | 'light' | 'bold'} IconStyleId */ /** @typedef {'lucide' | 'hugeicons'} IconSetId */ -/** @typedef {{ theme: ThemeId, zoom: number, font: FontId, iconStyle: IconStyleId, iconSet: IconSetId, mcpAutoStart: boolean, launchAtLogin: boolean, autoReconnectOnStartup: boolean, previewDmlBeforeApply: boolean }} AppSettings */ +/** @typedef {{ theme: ThemeId, zoom: number, font: FontId, iconStyle: IconStyleId, iconSet: IconSetId, tableStyle: TableStyleId, mcpAutoStart: boolean, launchAtLogin: boolean, autoReconnectOnStartup: boolean, previewDmlBeforeApply: boolean }} AppSettings */ /** UI zoom scale (font + layout). 1 = 100%. */ export const ZOOM_STEPS = [0.8, 0.85, 0.9, 0.95, 1, 1.05, 1.1, 1.15, 1.25, 1.5] @@ -89,6 +89,36 @@ function normalizeIconSet(/** @type {unknown} */ id) { return ICON_SETS[/** @type {IconSetId} */ (id)] ? /** @type {IconSetId} */ (id) : DEFAULT_ICON_SET } +/** + * @typedef {'lines'|'dotted'|'dots'|'minimal'|'bordered'|'striped'} TableStyleId + * @typedef {{ label: string, description: string, + * rows: boolean, cols: boolean, dash: number[]|null, dots: boolean, strong?: boolean, zebra?: boolean }} TableStyleDef + */ + +/** + * Data-grid style presets for the canvas table. Each preset only changes how the + * per-row grid pass draws separators — it's applied in DataTable's virtualized + * draw(), so it costs O(visible cells) and never scales with total row count. + * - rows/cols: draw horizontal / vertical separators + * - dash: canvas setLineDash pattern (null = solid) + * - dots: draw a small dot at each cell join instead of lines + * @type {Record} + */ +export const TABLE_STYLES = { + lines: { label: 'Lines', description: 'Solid grid lines (classic)', rows: true, cols: true, dash: null, dots: false }, + bordered: { label: 'Bordered', description: 'Bold high-contrast grid lines', rows: true, cols: true, dash: null, dots: false, strong: true }, + striped: { label: 'Striped', description: 'Alternating even/odd row shading', rows: true, cols: false, dash: null, dots: false, zebra: true }, + dotted: { label: 'Dotted', description: 'Fine dotted grid, softer feel', rows: true, cols: true, dash: [1, 3], dots: false }, + dots: { label: 'Dots', description: 'Corner dots + soft row shading', rows: false, cols: false, dash: null, dots: true, zebra: true }, + minimal: { label: 'Minimal', description: 'Row separators only, no columns', rows: true, cols: false, dash: null, dots: false }, +} +/** @type {TableStyleId} */ +export const DEFAULT_TABLE_STYLE = 'lines' +/** @returns {TableStyleId} */ +export function normalizeTableStyle(/** @type {unknown} */ id) { + return TABLE_STYLES[/** @type {TableStyleId} */ (id)] ? /** @type {TableStyleId} */ (id) : DEFAULT_TABLE_STYLE +} + /** @type {AppSettings} */ export const DEFAULT_SETTINGS = { theme: DEFAULT_THEME_ID, @@ -96,6 +126,7 @@ export const DEFAULT_SETTINGS = { font: DEFAULT_FONT, iconStyle: DEFAULT_ICON_STYLE, iconSet: DEFAULT_ICON_SET, + tableStyle: DEFAULT_TABLE_STYLE, mcpAutoStart: false, launchAtLogin: false, autoReconnectOnStartup: true, @@ -121,6 +152,10 @@ export const appThemeId = writable(/** @type {ThemeId} */ (DEFAULT_THEME_ID)) /** Reactive: show a SQL preview/confirm before applying grid writes (synced by applySettings). */ export const appPreviewDml = writable(true) +/** Reactive canvas-table grid style preset (synced by applySettings). DataTable + * subscribes to repaint when it changes. */ +export const appTableStyle = writable(/** @type {TableStyleId} */ (DEFAULT_TABLE_STYLE)) + const LAST_DARK_KEY = 'stroke:last-dark-theme' const LAST_LIGHT_KEY = 'stroke:last-light-theme' @@ -192,7 +227,8 @@ export function loadSettings() { const font = normalizeFont(parsed.font) const iconStyle = normalizeIconStyle(parsed.iconStyle) const iconSet = normalizeIconSet(parsed.iconSet) - _settingsCache = { theme, zoom, font, iconStyle, iconSet, mcpAutoStart, launchAtLogin, autoReconnectOnStartup, previewDmlBeforeApply } + const tableStyle = normalizeTableStyle(parsed.tableStyle) + _settingsCache = { theme, zoom, font, iconStyle, iconSet, tableStyle, mcpAutoStart, launchAtLogin, autoReconnectOnStartup, previewDmlBeforeApply } return { ..._settingsCache } } catch { return { ...DEFAULT_SETTINGS } @@ -261,6 +297,12 @@ export function applySettings(settings) { // Grid-write DML preview toggle — DataTable subscribes to gate its confirm dialog. appPreviewDml.set(settings.previewDmlBeforeApply !== false) + // Canvas table grid style — data attribute for any CSS hooks; DataTable reads + // the store and repaints the virtualized grid pass. + const tableStyle = normalizeTableStyle(settings.tableStyle) + root.setAttribute('data-table-style', tableStyle) + appTableStyle.set(tableStyle) + // Keep the canvas-table zoom in lockstep with the app zoom so Cmd +/-/0 (and // the zoom buttons) scale the grid alongside the rest of the UI. The canvas // renderer reads zoomState directly and repaints on change. From 9449f621da52e248779c5245c42a186e6bd7fede Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 14 Jul 2026 23:16:57 +0545 Subject: [PATCH 3/4] docs(changeset): array editor, table styles, keyring fix & UI polish --- .changeset/array-editor-and-improvements.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .changeset/array-editor-and-improvements.md diff --git a/.changeset/array-editor-and-improvements.md b/.changeset/array-editor-and-improvements.md new file mode 100644 index 00000000..f9006154 --- /dev/null +++ b/.changeset/array-editor-and-improvements.md @@ -0,0 +1,19 @@ +### New Features + +#### Data Table +- **Array cell editor** — Postgres array columns (`text[]`, `int[]`, …) get a dedicated add / remove / reorder editor instead of being edited as raw JSON, and now display as `{a,b}` array literals. +- **Grid styles** — choose from six table grid styles in Settings: Lines, Bordered, Striped, Dotted, Dots, and Minimal. +- **DML preview** — review (and edit) the prettified SQL for a change before it's applied. + +#### Interface +- **Provider sign-in** — refreshed the connection screen and provider sign-in flow. +- **More keyboard shortcuts** — reopen closed tab, jump to tab 1–9, toggle the tab bar, and disconnect. +- **Auto-reconnect** — optionally reconnect to your last database on startup. + +### Bug Fixes + +#### Data +- **Array columns rendered as garbage** — Postgres array values were decoded from the binary wire format as lossy UTF-8 (□ boxes); they now decode into proper arrays. + +#### Security +- **Saved credentials not persisting** — the OS keychain integration now enables a real per-platform backend, so AI keys and OAuth tokens actually persist (previously a missing backend feature silently used a non-persistent in-memory store on some setups). From d97a3c6ca1f2324ecaae1a178fd6bd04390e6ddd Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 14 Jul 2026 23:21:55 +0545 Subject: [PATCH 4/4] docs(readme): document array editor + table grid styles shipping this release --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 59fca2c6..a625a6c8 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ Connect directly or through an **SSH tunnel** for databases behind a bastion hos - Multi-column sort — shift-click headers to add secondary keys - Full-text search and a visual filter builder, with relative date-range presets and enum value pickers - Click any foreign-key value to jump to the referenced row +- Choose from six grid styles (Lines, Bordered, Striped, Dotted, Dots, Minimal) in **Settings → Appearance** **Cell right-click menu** @@ -84,8 +85,10 @@ Sort ascending · Sort descending · Filter by this column · Pin column · Hide ### Inline editing -Edit text, numbers, booleans, enums, dates, UUIDs, arrays, and JSON in place. +Edit text, numbers, booleans, enums, dates, UUIDs, and JSON in place. Insert rows with smart defaults, delete rows, and set any cell to NULL in one action. +Postgres array columns (`text[]`, `int[]`, …) get a dedicated add / remove / reorder editor. +Optionally preview the generated SQL for any change before it's applied. ### Row inspector