v0.5.0 — Grid Interactivity, Home Polish, Deeper PostgreSQL (#8)

* chore: bump version to 0.5.0 (Task 1)

* feat(store): v7 migration — favorites + recent_connections (Task 2)

* feat(models): add favorite to Connection + Store CRUD (Task 3)

* feat(types): ColumnInfo editability + IndexInfo/ConstraintInfo/RecentConnection (Task 4)

* chore: bump version to 0.5.0 (Task 1) — lockfile

* feat(commands): typed wrappers for favorites/recents/indexes/constraints (Task 5)

* feat(db): PG indexes/constraints queries + matview UNION in tables (Task 6)

* feat(db): get_table_data editability flags + ctid/rowid locator (Task 7)

* feat(db): execute_change no-PK locator guard + affected-count check (Task 8)

* feat(db): preserve bigint precision as string on PG read path (Task 9)

* feat(db): get_indexes / get_constraints commands (Task 10)

* feat(store): favorites + recents Store methods (Task 11)

* feat(commands): favorites/recents IPC + register indexes/constraints (Task 12)

* feat(store): connectionStore favorites/recents/move-selection (Task 13)

* feat(lib): recent-connections pure helpers (Task 14)

* feat(store): dbViewerStore indexes/constraints + stageCellEdit (Task 15)

* feat(grid): pure editability + filter-operator + cell transform (Task 16)

* feat(grid): pure keyboard-nav helper (Task 17)

* feat(grid): CellEditor inline editor (Task 18)

* feat(grid): CellContextMenu + RowDetailDrawer (Task 19)

* feat(grid): focus model + keyboard nav + inline edit + copy + context menu (Task 20)

* feat(db-viewer): FilterBuilder drag-and-drop + type-aware operators (Task 21)

* feat(db-viewer): ObjectExplorer indexes/constraints/procedures + matview icon (Task 22)

* feat(home): ConnectionCard favorite star + on-demand StatusDot (Task 23)

* feat(home): move-to-folder + recents strip + status wiring (Task 24)

* feat(db-viewer): grid wiring + matview read-only + post-commit refetch (Task 25)

* docs: v0.5.0 status + roadmap updates (Task 26)

* polish: empty/error/loading states for v0.5.0 surfaces (Task 28)

* feat(home): duplicateConnection + useConnectionStatus hook, drop StatusDot (FEAT-A)

* feat(home): connection card kebab menu — favorite/test/manage (FEAT-B)

* fix(home): populate server_version/latency_ms in test_connection + clean online display

* style(home): swap grab handle and kebab positions on connection card

* style(home): nudge kebab menu to right-1

* style(home): nudge kebab menu to right-0.5

* feat(home): Escape clears + exits focused search

* feat(grid): context-menu View/Select Row, outside-click close, Esc cancels edit, FK reference (GRID-A)

* feat(grid): smart CellEditor — enum select, FK searchable dropdown, textarea height (GRID-B)

* feat(grid): enums + FK options fed into CellEditor (GRID-C)

* feat(grid): FK dropdown display-column labels + placeholder + empty state

* fix(grid): FK dropdown renders as fixed overlay to avoid clipping

* fix(grid): portal FK dropdown to body + FK reference icon instead of click-to-open

* style(grid): move FK reference icon to the start of the cell

* feat(grid): optimistic staged cell values + pending dot, cleared on refetch

* fix(grid): queue is source of truth for staged values — value diff, Clear All clears dots, same-cell edits replace

* fix(db-viewer): type getLocator for staged-value matching

* test(db-viewer): unit-test deriveStagedValues; fix activeTab null guard

* fix(db-viewer): pass table prop to VirtualDataGrid — staged edits now carry the table name

* test(db-viewer): use index access instead of .at() for TS lib target

* feat(grid): FK dropdown options as one-row column cells (FK-reference style, cap 5)

* style(grid): FK dropdown — values only, fixed 360px width, FK-viewer surface styling

* style(grid): harden FK dropdown minWidth to 360px

* style(grid): cap FK dropdown cells at 3

* style(grid): cap FK dropdown cells at 4

* fix(grid): pending dot clears on commit — values stay until refetch

* fix(db): deserialize pg_attribute char columns as i8 — no more panic on get_table_data

* docs: reflect grid interactivity, smart editors, optimistic queue, FK reference, kebab status
This commit is contained in:
2026-08-03 02:42:24 +08:00
committed by GitHub
parent e0c0db8352
commit 16888460b7
77 changed files with 5671 additions and 265 deletions
+15 -12
View File
@@ -207,15 +207,16 @@ cargo test # Rust tests
| Folder tag matching | ✅ | When any filter is active, folder cards show only if the folder matches a selected tag OR contains matching connections (directly or in subfolders) | | Folder tag matching | ✅ | When any filter is active, folder cards show only if the folder matches a selected tag OR contains matching connections (directly or in subfolders) |
| DB type filter (Postgres/MySQL/SQLite/Redis) | ✅ | Dropdown with checkboxes + Clear all; folder cards hidden when their contents don't match the DB type | | DB type filter (Postgres/MySQL/SQLite/Redis) | ✅ | Dropdown with checkboxes + Clear all; folder cards hidden when their contents don't match the DB type |
| Environment filter | ✅ | Select in Filters dropdown: All / Production / Staging / Development / None (unassigned); counts toward active badge | | Environment filter | ✅ | Select in Filters dropdown: All / Production / Staging / Development / None (unassigned); counts toward active badge |
| Global search (Cmd+K) | ✅ | Connection URL detection auto-fills new-connection form; shows results from ALL folders as if at root (folder scope bypassed while searching); breadcrumb shows "Showing Search Results" with Clear button | | Global search (Cmd+K) | ✅ | Connection URL detection auto-fills new-connection form; shows results from ALL folders as if at root (folder scope bypassed while searching); breadcrumb shows "Showing Search Results" with Clear button; **Esc while the search is focused clears the query, exits search mode and blurs** |
| Connection name editing | ✅ | Name field in GeneralTab edit form | | Connection name editing | ✅ | Name field in GeneralTab edit form |
| Import/Export connections (JSON) | ✅ | Bulk import with validation, skipped-record reporting | | Import/Export connections (JSON) | ✅ | Bulk import with validation, skipped-record reporting |
| Bulk select + delete connections/folders | ✅ | Checkbox selection with confirmation dialog | | Bulk select + delete connections/folders | ✅ | Checkbox selection with confirmation dialog |
| Drag-and-drop connections to folders | ✅ | Optimistic update with atomic snapshot rollback (race-condition hardened) | | Drag-and-drop connections to folders | ✅ | Optimistic update with atomic snapshot rollback (race-condition hardened) |
| Inline tag creation | ✅ | "Create first tag" inline form (name + color) in SearchableTagPicker empty state | | Inline tag creation | ✅ | "Create first tag" inline form (name + color) in SearchableTagPicker empty state |
| Move-to-folder bulk action | | | | Move-to-folder bulk action | | Selection toolbar → Move to Folder dialog (folder picker, move confirmed via dialog) |
| Favorites / Recent connections | | | | Favorites / Recent connections | | Star toggle in the connection card ⋮ menu (persisted `favorite` flag); Recent connections row (top 8 via `getRecentConnections`) |
| Connection status indicator on cards | | | | Connection status indicator on cards | | Kebab menu → Test connection with inline idle/checking/online/offline result, on-demand via keychain + `testConnection`. **Reports real `server_version` + `latency_ms`** (PG/MySQL/SQLite queries + connect timing in the Rust backend); shows `Online · 16.4 · 42ms` or the error, re-check debounced 2s |
| Connection card actions menu (⋮) | ✅ | Kebab dropdown: Favorite toggle, Test connection (inline status), Manage submenu (Edit… / Duplicate / Delete…) |
### Database Viewer ### Database Viewer
| Feature | Status | Details | | Feature | Status | Details |
@@ -231,6 +232,7 @@ cargo test # Rust tests
| 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` |
| 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 |
@@ -243,11 +245,12 @@ cargo test # Rust tests
| 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 | | Cells are read-only; changes via queue Insert button only | | Inline cell editing | | Double-click/Enter edits a cell; commit stages an `update` change in the queue → Commit All. No-PK tables use ctid/rowid locator; PK/generated/identity columns and views/matviews are read-only. Stale-write protection via affected-row-count check. **Optimistic UI**: the changes queue is the single source of truth — `deriveStagedValues` feeds staged values + a pending amber dot back into the grid (dot clears on commit, values survive until refetch); re-editing the same cell replaces the queue entry (original `oldData` kept); Clear All removes dots instantly; queue cards show an old → new value diff. **Smart editors**: PG enum columns render a `<select>` of enum labels; FK columns render a searchable dropdown of referenced rows (one row per option showing the first 4 referenced columns, FK-popover styling, 360px fixed, portal to body); `text`/`json`/`jsonb` use a single-line scrolling textarea. |
| Virtualized data grid | ✅ | Row-level virtualization via @tanstack/react-virtual `useVirtualizer`; handles 100k+ rows | | Virtualized data grid | ✅ | Row-level virtualization via @tanstack/react-virtual `useVirtualizer`; handles 100k+ rows |
| Row detail / expandable row view | | | | Row detail / expandable row view | | RowDetailDrawer: right-drawer per row, opened via the cell context menu **View Row** |
| Keyboard cell navigation (arrow keys, Tab) | | | | 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 |
| Cell-level copy (right-click or Ctrl+C) | | Only bulk copy via toolbar | | Cell-level copy (right-click or Ctrl+C) | | CellContextMenu: Copy / Copy JSON (jsonb) / Edit / Set NULL / Open FK reference + **View Row** and **Select Row** items; right-click or Ctrl/Cmd+C on the focused cell; menu closes on outside click/Esc |
| FK reference | ✅ | Small ↗ icon at the start of FK cells opens the FK preview popover (also via context menu Open FK reference); plain click on the cell selects/edits and does not open it |
### Object Explorer (non-table objects) ### Object Explorer (non-table objects)
| Feature | Status | Details | | Feature | Status | Details |
@@ -258,10 +261,10 @@ cargo test # Rust tests
| Sequences | ✅ | Full detail view: current value, increment, start, min/max, cycle flag. Schema-filtered via information_schema.sequences. | | Sequences | ✅ | Full detail view: current value, increment, start, min/max, cycle flag. Schema-filtered via information_schema.sequences. |
| Enums | ✅ | Full detail view: numbered bordered list matching Arguments style. Schema-filtered via pg_type WHERE typtype='e'. | | Enums | ✅ | Full detail view: numbered bordered list matching Arguments style. Schema-filtered via pg_type WHERE typtype='e'. |
| Extensions | ✅ | Full detail view: version, schema, comment. Queried from pg_extension (no schema filter — extensions are DB-scoped). | | Extensions | ✅ | Full detail view: version, schema, comment. Queried from pg_extension (no schema filter — extensions are DB-scoped). |
| Indexes (per table) | | | | Indexes (per table) | | Per-table index list with columns, method, unique/partial flags (via pg_indexes) |
| Constraints (CHECK, UNIQUE beyond PK/FK) | | | | Constraints (CHECK, UNIQUE beyond PK/FK) | | CHECK/UNIQUE constraints beyond PK/FK, introspected via information_schema |
| Materialized views | | Not distinguished from regular views | | Materialized views | | Distinct icon in table tree, browsable, read-only (via pg_matviews) |
| Stored procedures | 🟡 | Included in Functions via p.prokind IN ('f','p'); no separate view yet | | Stored procedures | | Procedures object type filters prokind='p'; Functions now filters kind='f' |
| Schema visualizer (ER diagram) | ✅ | Full React Flow ER diagram with dagre auto-layout, crow's foot notation, schema selector, legend with cardinality colors, collapsible columns (PK/FK/unique-only), cross-schema FK support. PostgreSQL (single round-trip LATERAL query) + SQLite (PRAGMA). Uses @xyflow/react + dagre. | | Schema visualizer (ER diagram) | ✅ | Full React Flow ER diagram with dagre auto-layout, crow's foot notation, schema selector, legend with cardinality colors, collapsible columns (PK/FK/unique-only), cross-schema FK support. PostgreSQL (single round-trip LATERAL query) + SQLite (PRAGMA). Uses @xyflow/react + dagre. |
### Query Editor ### Query Editor
+23 -8
View File
@@ -20,11 +20,19 @@ Most database GUI clients either lock essential productivity features behind pay
| pg_dump / pg_restore GUI | ❌ | ❌ (paid only) | **First-class UI** | | pg_dump / pg_restore GUI | ❌ | ❌ (paid only) | **First-class UI** |
| DB-to-DB sync | ❌ | ❌ | **Built-in pipe sync** | | DB-to-DB sync | ❌ | ❌ | **Built-in pipe sync** |
| Object explorer depth | Tables, views | Tables, views | **Functions, Triggers, Enums, Sequences, Extensions** | | Object explorer depth | Tables, views | Tables, views | **Functions, Triggers, Enums, Sequences, Extensions** |
| Indexes, constraints, matviews, procedures | 🔒 paid | ❌ | **✅ Full object views** |
| ER diagram / schema visualizer | ❌ (planned) | ❌ (paid only) | **✅ Interactive React Flow** | | ER diagram / schema visualizer | ❌ (planned) | ❌ (paid only) | **✅ Interactive React Flow** |
| Inline cell editing | ✅ | ✅ | 🟡 *Upcoming* | | Inline cell editing | ✅ | ✅ | **✅ Inline editing** (double-click / Enter, ctid/rowid locator, stale-write guard; optimistic staged values + pending dot from the changes queue; smart editors — PG enum dropdowns and searchable FK dropdowns of referenced rows) |
| Keyboard cell navigation | ✅ | ✅ | **✅** (arrows + Tab wrap, Esc cancels editing) |
| Cell copy (right-click / Ctrl+C) | ✅ | ✅ | **✅** (context menu: Copy / Copy JSON / Edit / Set NULL / Open FK / View Row / Select Row) |
| Row detail drawer | ✅ | ✅ | **✅** (via context menu View Row) |
| Visual filter builder | ✅ | ✅ | **✅** (drag-and-drop, type-aware operators) |
| SSH tunneling | 🟡 (likely paid) | ✅ | **✅ Full tunnel (password + key auth, keychain)** | | SSH tunneling | 🟡 (likely paid) | ✅ | **✅ Full tunnel (password + key auth, keychain)** |
| OS credential vault | ✅ | ✅ | **Keychain / Secret Service** | | OS credential vault | ✅ | ✅ | **Keychain / Secret Service** |
| Workspace / folder hierarchy | ❌ | ❌ | **Multi-level tree + tags** | | Workspace / folder hierarchy | ❌ | ❌ | **Multi-level tree + tags** |
| Favorites / Recent connections | ✅ | ✅ | **✅ Star + recents row** |
| Connection status indicator | ❌ | ✅ | **✅ On-demand click-to-test dot** |
| Bulk move-to-folder | ✅ | ✅ | **✅ Selection toolbar → folder picker** |
| Changes queue (stage & commit) | ❌ | ❌ | **✅ Queue → Commit All** (tab-bar **Changes** button with count badge toggles a popover: Visual/SQL preview, per-change revert, Clear All, ⌘S commit) | | Changes queue (stage & commit) | ❌ | ❌ | **✅ Queue → Commit All** (tab-bar **Changes** button with count badge toggles a popover: Visual/SQL preview, per-change revert, Clear All, ⌘S commit) |
| Query history | ✅ (auto-saved) | ✅ | **✅ Toolbar dropdown, favorites, Queries view** | | Query history | ✅ (auto-saved) | ✅ | **✅ Toolbar dropdown, favorites, Queries view** |
| AI assistant | ✅ (BYO key) | ❌ (paid only) | 🔮 *Planned — BYOK* | | AI assistant | ✅ (BYO key) | ❌ (paid only) | 🔮 *Planned — BYOK* |
@@ -84,6 +92,9 @@ Most database GUI clients either lock essential productivity features behind pay
- **URI Parser** — paste `postgres://`, `mysql://`, `sqlite://`, or `redis://` connection strings and have all fields auto-populated - **URI Parser** — paste `postgres://`, `mysql://`, `sqlite://`, or `redis://` connection strings and have all fields auto-populated
- **Workspace Tree** — multi-level hierarchy: `Workspace → Folder → Connection`, with color-coded tags (red = Production, green = Local) - **Workspace Tree** — multi-level hierarchy: `Workspace → Folder → Connection`, with color-coded tags (red = Production, green = Local)
- **Credential Security** — passwords stored in the OS keychain (macOS Keychain / Linux Secret Service / Windows Credential Manager), never as plaintext - **Credential Security** — passwords stored in the OS keychain (macOS Keychain / Linux Secret Service / Windows Credential Manager), never as plaintext
- **Favorites & Recent Connections** — star your go-to connections; the Home screen surfaces a Recent row
- **Connection Status Indicator** — on-demand per-card dot that tests the connection through the keychain + `testConnection` (idle/checking/online/offline)
- **Bulk Move-to-Folder** — select multiple connections and move them to a folder in one step
- **Production Safeguards** — read-only locks and high-visibility warnings on connections tagged `Production` - **Production Safeguards** — read-only locks and high-visibility warnings on connections tagged `Production`
### PostgreSQL Object Explorer ### PostgreSQL Object Explorer
@@ -95,6 +106,9 @@ Full tree-view navigation of all native PostgreSQL schema objects:
- **Extensions** — installed extensions with version, schema, and comment - **Extensions** — installed extensions with version, schema, and comment
- **Schema Visualizer (ER Diagram)** — interactive React Flow graph with dagre auto-layout, crow's foot notation (1:1, 1:N, N:M), color-coded relationships, schema selector, zoom controls, collapsible columns (PK/FK/unique-only), cross-schema FK support for PostgreSQL + SQLite - **Schema Visualizer (ER Diagram)** — interactive React Flow graph with dagre auto-layout, crow's foot notation (1:1, 1:N, N:M), color-coded relationships, schema selector, zoom controls, collapsible columns (PK/FK/unique-only), cross-schema FK support for PostgreSQL + SQLite
- **Unified Objects View** — Functions, Triggers, Sequences, Enums, and Extensions share a single sidebar with an object-type dropdown switcher (title position), refresh/search, and db/schema selectors - **Unified Objects View** — Functions, Triggers, Sequences, Enums, and Extensions share a single sidebar with an object-type dropdown switcher (title position), refresh/search, and db/schema selectors
- **Indexes & Constraints** — per-table index list (columns, method, unique/partial flags) and CHECK/UNIQUE constraints beyond PK/FK
- **Materialized Views** — distinct icon in the table tree, browsable (read-only)
- **Stored Procedures** — dedicated Procedures object type (`prokind='p'`); Functions now filters `prokind='f'`
### SQL Editor & Query Workbench ### SQL Editor & Query Workbench
- **Monaco SQL Editor** — lazy-loaded [Monaco Editor](https://microsoft.github.io/monaco-editor/) with SQL syntax highlighting, Cmd/Ctrl+Enter to run - **Monaco SQL Editor** — lazy-loaded [Monaco Editor](https://microsoft.github.io/monaco-editor/) with SQL syntax highlighting, Cmd/Ctrl+Enter to run
@@ -114,10 +128,14 @@ Full tree-view navigation of all native PostgreSQL schema objects:
- **Column Management** — resize with drag handles (double-click to auto-fit), show/hide per column, multi-column sort - **Column Management** — resize with drag handles (double-click to auto-fit), show/hide per column, multi-column sort
- **Server-Side Filtering & Sorting** — filters and sorts pushed to SQL WHERE/ORDER BY - **Server-Side Filtering & Sorting** — filters and sorts pushed to SQL WHERE/ORDER BY
- **Export** — JSON, CSV, SQL, Markdown via toolbar - **Export** — JSON, CSV, SQL, Markdown via toolbar
- **FK Preview** — click a foreign key cell to preview the referenced row - **FK Preview** — ↗ icon at the start of FK cells (or context menu → Open FK reference) opens a popover with the referenced row; opens a filtered tab on demand
- **JSON/JSONB Viewer** — popover with formatted/raw tabs and copy button - **JSON/JSONB Viewer** — popover with formatted/raw tabs and copy button
- **Auto-Refresh** — configurable interval timer - **Auto-Refresh** — configurable interval timer
- *(Inline cell editing and visual filter builder — upcoming)* - **Inline Cell Editing** — double-click/Enter to edit cells; changes stage through the queue → Commit All (ctid/rowid locator for no-PK tables, PK/generated/identity read-only, stale-write guard via affected-row-count). Cells show the staged value + an amber pending dot immediately (queue is the source of truth; dot clears on commit, values survive until refetch); queue cards show an old → new diff; re-editing a cell replaces its queue entry
- **Smart Cell Editors** — PG enum columns edit via a dropdown of enum labels; FK columns edit via a searchable dropdown of referenced rows (one row per option showing the first 4 referenced columns, FK-popover styling)
- **Keyboard Navigation** — arrow keys + Tab/Shift+Tab wrap; Esc cancels editing even when the editor has lost focus
- **Cell Copy + Context Menu** — right-click / Ctrl+C copy; menu items: Copy, Copy JSON, Edit, Set NULL, Open FK reference, View Row, Select Row; closes on outside click/Esc
- **Visual Filter Builder** — drag-and-drop column palette with type-aware operators (AND semantics, persists per tab)
### PostgreSQL Administrative Tools ### PostgreSQL Administrative Tools
- **Visual Backup** — `pg_dump` wrapper with format selector (Plain SQL, Custom, Tar, Directory), file browser, schema filter, no-owner toggle, real-time progress bar - **Visual Backup** — `pg_dump` wrapper with format selector (Plain SQL, Custom, Tar, Directory), file browser, schema filter, no-owner toggle, real-time progress bar
@@ -240,15 +258,12 @@ gridline/
- **Consolidated Navigation** — merged Functions/Triggers/Sequences/Enums/Extensions into a single Objects view (object-type dropdown) and Backup/Restore/DB Sync into a single Tools view (operation dropdown) - **Consolidated Navigation** — merged Functions/Triggers/Sequences/Enums/Extensions into a single Objects view (object-type dropdown) and Backup/Restore/DB Sync into a single Tools view (operation dropdown)
- **Settings (Redesigned & Fully Wired)** — DB-viewer-styled settings screen (icon+text sidebar, tab-titled header, border-sharp no-card sections, Back returns to origin view); all settings functional: theme (light/dark/system, applied live + native macOS Overlay titlebar sync), font size, **accent color** (circle palette), default folder on startup, confirm-before-delete toggle, default ports prefill; drag-and-drop tag reorder - **Settings (Redesigned & Fully Wired)** — DB-viewer-styled settings screen (icon+text sidebar, tab-titled header, border-sharp no-card sections, Back returns to origin view); all settings functional: theme (light/dark/system, applied live + native macOS Overlay titlebar sync), font size, **accent color** (circle palette), default folder on startup, confirm-before-delete toggle, default ports prefill; drag-and-drop tag reorder
- **Editor Settings, SSH/SSL Runtime, Data Import** — Monaco editor options (font size/family, word wrap, minimap, tab size) applied live; real SSH tunnel (`ssh2`, password + key auth, keychain secrets, full lifecycle) and TLS (`rustls`, all modes + client certs) for PostgreSQL/MySQL; CSV/JSON import with preview + column mapping through the changes queue; table-menu loose ends (Copy table schema DDL, Empty/Delete Table via queue, export stubs wired) - **Editor Settings, SSH/SSL Runtime, Data Import** — Monaco editor options (font size/family, word wrap, minimap, tab size) applied live; real SSH tunnel (`ssh2`, password + key auth, keychain secrets, full lifecycle) and TLS (`rustls`, all modes + client certs) for PostgreSQL/MySQL; CSV/JSON import with preview + column mapping through the changes queue; table-menu loose ends (Copy table schema DDL, Empty/Delete Table via queue, export stubs wired)
- **v0.5.0 — Grid Interactivity, Home Polish, Deeper PostgreSQL** — inline cell editing (ctid/rowid locator, stale-write guard), keyboard navigation, cell copy + context menu, row-detail drawer, visual filter builder, bulk move-to-folder, favorites + recents, on-demand connection status indicator, PG Indexes/Constraints/Materialized Views/Stored Procedures object views; version 0.5.0.
### 🟡 In Progress / Upcoming
- **Inline Cell Editing** — Edit cells directly in the data grid
- **Visual Filter Builder** — drag-and-drop filter construction
### 🔮 Future ### 🔮 Future
- **Multi-DB Support** — MySQL browsing, Redis key browser, full MySQL/SQLite/Redis parity with PostgreSQL - **Multi-DB Support** — MySQL browsing, Redis key browser, full MySQL/SQLite/Redis parity with PostgreSQL
- **Query Workbench** — Multiple result sets, visual query builder - **Query Workbench** — Multiple result sets, visual query builder
- **Deeper PostgreSQL** — Indexes, constraints, materialized views, stored procedure view, user/role management - **Deeper PostgreSQL** — user/role management, replication
- **Notebook Reports** — SQL-backed markdown reports with embedded results - **Notebook Reports** — SQL-backed markdown reports with embedded results
- **AI Integration (BYOK)** — Bring-Your-Own-Key AI assistant: natural-language → SQL generation, query explanations, schema summaries, error suggestions. Key stored in OS keychain; only user's chosen provider sees SQL/text. - **AI Integration (BYOK)** — Bring-Your-Own-Key AI assistant: natural-language → SQL generation, query explanations, schema summaries, error suggestions. Key stored in OS keychain; only user's chosen provider sees SQL/text.
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "gridline", "name": "gridline",
"private": true, "private": true,
"version": "0.2.0", "version": "0.5.0",
"description": "An open-source, high-performance database GUI client for PostgreSQL and beyond", "description": "An open-source, high-performance database GUI client for PostgreSQL and beyond",
"type": "module", "type": "module",
"scripts": { "scripts": {
+1 -1
View File
@@ -1783,7 +1783,7 @@ dependencies = [
[[package]] [[package]]
name = "gridline" name = "gridline"
version = "0.2.0" version = "0.5.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"deadpool-postgres", "deadpool-postgres",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "gridline" name = "gridline"
version = "0.2.0" version = "0.5.0"
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"
+126
View File
@@ -122,6 +122,66 @@ pub fn add_connection_tags(
add_connection_tags_inner(&state.db_store, connection_id, tag_ids) add_connection_tags_inner(&state.db_store, connection_id, tag_ids)
} }
pub fn set_connection_favorite_inner(
state: &Mutex<Store>,
connection_id: String,
favorite: bool,
) -> Result<(), String> {
let store = state.lock().map_err(|e| e.to_string())?;
store.set_connection_favorite(&connection_id, favorite)
}
pub fn record_recent_connection_inner(
state: &Mutex<Store>,
connection_id: String,
) -> Result<(), String> {
let store = state.lock().map_err(|e| e.to_string())?;
store.record_recent_connection(&connection_id)
}
pub fn get_recent_connections_inner(
state: &Mutex<Store>,
limit: i64,
) -> Result<Vec<crate::models::RecentConnection>, String> {
let store = state.lock().map_err(|e| e.to_string())?;
store.get_recent_connections(limit)
}
pub fn clear_recent_connections_inner(state: &Mutex<Store>) -> Result<(), String> {
let store = state.lock().map_err(|e| e.to_string())?;
store.clear_recent_connections()
}
#[tauri::command]
pub fn set_connection_favorite(
state: tauri::State<crate::AppState>,
connection_id: String,
favorite: bool,
) -> Result<(), String> {
set_connection_favorite_inner(&state.db_store, connection_id, favorite)
}
#[tauri::command]
pub fn record_recent_connection(
state: tauri::State<crate::AppState>,
connection_id: String,
) -> Result<(), String> {
record_recent_connection_inner(&state.db_store, connection_id)
}
#[tauri::command]
pub fn get_recent_connections(
state: tauri::State<crate::AppState>,
limit: i64,
) -> Result<Vec<crate::models::RecentConnection>, String> {
get_recent_connections_inner(&state.db_store, limit)
}
#[tauri::command]
pub fn clear_recent_connections(state: tauri::State<crate::AppState>) -> Result<(), String> {
clear_recent_connections_inner(&state.db_store)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -232,4 +292,70 @@ mod tests {
delete_connection_inner(&st, &conn.id).unwrap(); delete_connection_inner(&st, &conn.id).unwrap();
assert_eq!(get_connections_inner(&st).unwrap().len(), 0); assert_eq!(get_connections_inner(&st).unwrap().len(), 0);
} }
#[test]
fn set_connection_favorite_command_persists() {
let st = state();
let input = ConnectionInput {
name: "P".into(),
db_type: "postgresql".into(),
host: "h".into(),
port: Some(5432),
username: None,
folder_id: None,
tag_ids: vec![],
password: None,
database: None,
environment: None,
ssh_host: None,
ssh_port: None,
ssh_user: None,
ssh_auth_method: None,
ssh_private_key_path: None,
ssh_password: None,
ssh_passphrase: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
};
let conn = create_connection_inner(&st, input).unwrap();
set_connection_favorite_inner(&st, conn.id.clone(), true).unwrap();
assert_eq!(get_connections_inner(&st).unwrap()[0].favorite, true);
}
#[test]
fn record_recent_command_upserts() {
let st = state();
let input = ConnectionInput {
name: "P".into(),
db_type: "postgresql".into(),
host: "h".into(),
port: Some(5432),
username: None,
folder_id: None,
tag_ids: vec![],
password: None,
database: None,
environment: None,
ssh_host: None,
ssh_port: None,
ssh_user: None,
ssh_auth_method: None,
ssh_private_key_path: None,
ssh_password: None,
ssh_passphrase: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
};
let conn = create_connection_inner(&st, input).unwrap();
record_recent_connection_inner(&st, conn.id.clone()).unwrap();
record_recent_connection_inner(&st, conn.id.clone()).unwrap(); // idempotent upsert
let recent = get_recent_connections_inner(&st, 10).unwrap();
assert_eq!(recent.len(), 1);
clear_recent_connections_inner(&st).unwrap();
assert_eq!(get_recent_connections_inner(&st, 10).unwrap().len(), 0);
}
} }
+395 -39
View File
@@ -5,8 +5,8 @@
use crate::db::pool::{DbConfig, DbHandle}; use crate::db::pool::{DbConfig, DbHandle};
use crate::models::db_viewer::{ use crate::models::db_viewer::{
Change, ColumnInfo, EnumInfo, ExtensionInfo, FunctionInfo, QueryResult, Change, ColumnInfo, ConstraintInfo, EnumInfo, ExtensionInfo, FunctionInfo,
SequenceInfo, TableInfo, TriggerInfo, IndexInfo, QueryResult, SequenceInfo, TableInfo, TriggerInfo,
}; };
use std::collections::HashMap; use std::collections::HashMap;
use tauri::State; use tauri::State;
@@ -90,6 +90,40 @@ pub fn offset(page: i64, page_size: i64) -> i64 {
(page - 1) * page_size (page - 1) * page_size
} }
/// Split a `pg_get_indexdef(...,0,true)` / `pg_attribute` column CSV into a
/// Vec, trimming whitespace. Splits on commas that are NOT inside parens
/// (to keep expression-index columns intact).
pub(crate) fn split_columns_csv(csv: &str) -> Vec<String> {
let csv = csv.trim();
if csv.is_empty() {
return Vec::new();
}
let mut out = Vec::new();
let mut depth = 0i32;
let mut buf = String::new();
for ch in csv.chars() {
match ch {
'(' => {
depth += 1;
buf.push(ch);
}
')' => {
depth -= 1;
buf.push(ch);
}
',' if depth == 0 => {
out.push(buf.trim().to_string());
buf.clear();
}
_ => buf.push(ch),
}
}
if !buf.trim().is_empty() {
out.push(buf.trim().to_string());
}
out
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Table DDL helpers // Table DDL helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -275,6 +309,66 @@ fn build_order_clause(sorts: &[crate::models::db_viewer::SortRule]) -> String {
} }
} }
// ---------------------------------------------------------------------------
// Row-locator / editability helpers (Task 7)
// ---------------------------------------------------------------------------
/// Decide editability from pg_attribute flags: `attgenerated` ('' or 's'/'v')
/// and `attidentity` ('' or 'a'='ALWAYS' / 'd'='BY DEFAULT').
/// Generated (stored) columns and IDENTITY ALWAYS columns are non-editable.
pub(crate) fn editable_from_att(attgenerated: &str, attidentity: &str) -> bool {
attgenerated.is_empty() && attidentity != "a"
}
/// Convert pg_attribute's internal "char" (i8, OID 18) to the 1-char string
/// used by `editable_from_att`: '' = not set, 's' = STORED, 'v' = VIRTUAL,
/// 'a' = ALWAYS, 'd' = BY DEFAULT. `None`/`\0` → "" (safe, no panic).
pub(crate) fn pg_char_to_att(value: Option<i8>) -> String {
match value.and_then(|c| char::from_u32(c as u32)) {
Some(c) if c != '\0' => c.to_string(),
_ => String::new(),
}
}
/// Assemble a PG SELECT statement from pre-formatted select items (already
/// quoted and optionally `::text`-cast), appending `ctid` when the table has
/// no primary key so later UPDATE/DELETE queue changes can target the exact
/// row. `ctid` is appended last so it does not shift visible column order.
fn build_pg_select_from_items(schema: &str, table: &str, items: Vec<String>, has_pk: bool) -> String {
let mut all_cols = items;
if !has_pk {
all_cols.push("ctid".to_string());
}
format!("SELECT {} FROM \"{}\".\"{}\"", all_cols.join(", "), schema, table)
}
/// Build the PG data SELECT, appending `ctid` only when the table has no PK.
pub(crate) fn build_pg_data_select(
schema: &str,
table: &str,
visible_cols: &[String],
has_pk: bool,
) -> String {
let base_cols: Vec<String> = visible_cols.iter().map(|c| format!("\"{}\"", c)).collect();
build_pg_select_from_items(schema, table, base_cols, has_pk)
}
/// Build the SQLite data SELECT, appending `rowid` only when the table has no
/// PK. The table is unqualified; SQLite browsing in this app is always scoped
/// to the `main` schema, where an unqualified name resolves identically.
pub(crate) fn build_sqlite_data_select(
table: &str,
visible_cols: &[String],
has_pk: bool,
) -> String {
let base_cols: Vec<String> = visible_cols.iter().map(|c| format!("\"{}\"", c)).collect();
let mut all_cols = base_cols;
if !has_pk {
all_cols.push("rowid".to_string());
}
format!("SELECT {} FROM \"{}\"", all_cols.join(", "), table)
}
/// Build a parameterized UPDATE SQL statement. /// Build a parameterized UPDATE SQL statement.
/// ///
/// The returned SQL uses `?` placeholders for both the SET values and the /// The returned SQL uses `?` placeholders for both the SET values and the
@@ -289,7 +383,10 @@ pub fn build_update_sql(
table: &str, table: &str,
primary_key: &[(String, serde_json::Value)], primary_key: &[(String, serde_json::Value)],
new_data: &[(String, serde_json::Value)], new_data: &[(String, serde_json::Value)],
) -> String { ) -> Result<(String, Vec<serde_json::Value>), String> {
if primary_key.is_empty() {
return Err("cannot update a row without a primary key or row locator".to_string());
}
let set_clause: Vec<String> = new_data let set_clause: Vec<String> = new_data
.iter() .iter()
.map(|(col, _)| format!("\"{}\" = ?", col)) .map(|(col, _)| format!("\"{}\" = ?", col))
@@ -298,13 +395,22 @@ pub fn build_update_sql(
.iter() .iter()
.map(|(col, _)| format!("\"{}\" = ?", col)) .map(|(col, _)| format!("\"{}\" = ?", col))
.collect(); .collect();
// Params must follow placeholder order: SET values first, then WHERE.
let params: Vec<serde_json::Value> = new_data
.iter()
.chain(primary_key.iter())
.map(|(_, v)| v.clone())
.collect();
Ok((
format!( format!(
"UPDATE \"{}\".\"{}\" SET {} WHERE {}", "UPDATE \"{}\".\"{}\" SET {} WHERE {}",
schema, schema,
table, table,
set_clause.join(", "), set_clause.join(", "),
where_clause.join(" AND ") where_clause.join(" AND ")
) ),
params,
))
} }
/// Build a parameterized DELETE SQL statement. /// Build a parameterized DELETE SQL statement.
@@ -317,17 +423,23 @@ pub fn build_delete_sql(
schema: &str, schema: &str,
table: &str, table: &str,
primary_key: &[(String, serde_json::Value)], primary_key: &[(String, serde_json::Value)],
) -> String { ) -> Result<(String, Vec<serde_json::Value>), String> {
if primary_key.is_empty() {
return Err("cannot delete a row without a primary key or row locator".to_string());
}
let where_clause: Vec<String> = primary_key let where_clause: Vec<String> = primary_key
.iter() .iter()
.map(|(col, _)| format!("\"{}\" = ?", col)) .map(|(col, _)| format!("\"{}\" = ?", col))
.collect(); .collect();
Ok((
format!( format!(
"DELETE FROM \"{}\".\"{}\" WHERE {}", "DELETE FROM \"{}\".\"{}\" WHERE {}",
schema, schema,
table, table,
where_clause.join(" AND ") where_clause.join(" AND ")
) ),
primary_key.iter().map(|(_, v)| v.clone()).collect(),
))
} }
/// Build a parameterized INSERT SQL statement. /// Build a parameterized INSERT SQL statement.
@@ -367,7 +479,10 @@ pub fn build_pg_update_sql(
table: &str, table: &str,
primary_key: &[(String, serde_json::Value)], primary_key: &[(String, serde_json::Value)],
new_data: &[(String, serde_json::Value)], new_data: &[(String, serde_json::Value)],
) -> (String, Vec<serde_json::Value>) { ) -> Result<(String, Vec<serde_json::Value>), String> {
if primary_key.is_empty() {
return Err("cannot update a row without a primary key or row locator".to_string());
}
let mut params: Vec<serde_json::Value> = Vec::new(); let mut params: Vec<serde_json::Value> = Vec::new();
let set_clause: Vec<String> = new_data let set_clause: Vec<String> = new_data
.iter() .iter()
@@ -383,7 +498,7 @@ pub fn build_pg_update_sql(
format!("\"{}\" = ${}", col, params.len()) format!("\"{}\" = ${}", col, params.len())
}) })
.collect(); .collect();
( Ok((
format!( format!(
"UPDATE \"{}\".\"{}\" SET {} WHERE {}", "UPDATE \"{}\".\"{}\" SET {} WHERE {}",
schema, schema,
@@ -392,7 +507,7 @@ pub fn build_pg_update_sql(
where_clause.join(" AND ") where_clause.join(" AND ")
), ),
params, params,
) ))
} }
/// Build a PostgreSQL DELETE statement. /// Build a PostgreSQL DELETE statement.
@@ -400,7 +515,10 @@ pub fn build_pg_delete_sql(
schema: &str, schema: &str,
table: &str, table: &str,
primary_key: &[(String, serde_json::Value)], primary_key: &[(String, serde_json::Value)],
) -> (String, Vec<serde_json::Value>) { ) -> Result<(String, Vec<serde_json::Value>), String> {
if primary_key.is_empty() {
return Err("cannot delete a row without a primary key or row locator".to_string());
}
let mut params: Vec<serde_json::Value> = Vec::new(); let mut params: Vec<serde_json::Value> = Vec::new();
let where_clause: Vec<String> = primary_key let where_clause: Vec<String> = primary_key
.iter() .iter()
@@ -409,7 +527,7 @@ pub fn build_pg_delete_sql(
format!("\"{}\" = ${}", col, params.len()) format!("\"{}\" = ${}", col, params.len())
}) })
.collect(); .collect();
( Ok((
format!( format!(
"DELETE FROM \"{}\".\"{}\" WHERE {}", "DELETE FROM \"{}\".\"{}\" WHERE {}",
schema, schema,
@@ -417,7 +535,7 @@ pub fn build_pg_delete_sql(
where_clause.join(" AND ") where_clause.join(" AND ")
), ),
params, params,
) ))
} }
/// Build a PostgreSQL INSERT statement. /// Build a PostgreSQL INSERT statement.
@@ -661,13 +779,20 @@ fn sqlite_value_to_json(row: &rusqlite::Row, i: usize) -> serde_json::Value {
} }
} }
/// Serialize an i64 as a JSON string to preserve precision across the IPC
/// boundary (JS `Number` loses integer fidelity beyond 2^53). The frontend
/// treats numeric columns as strings for edit round-trips.
pub(crate) fn i64_to_json(v: i64) -> serde_json::Value {
serde_json::Value::String(v.to_string())
}
pub(crate) fn pg_value_to_json(row: &tokio_postgres::Row, i: usize) -> serde_json::Value { pub(crate) fn pg_value_to_json(row: &tokio_postgres::Row, i: usize) -> serde_json::Value {
// Integer types // Integer types
if let Ok(Some(v)) = row.try_get::<_, Option<i32>>(i) { if let Ok(Some(v)) = row.try_get::<_, Option<i32>>(i) {
return serde_json::json!(v); return serde_json::json!(v);
} }
if let Ok(Some(v)) = row.try_get::<_, Option<i64>>(i) { if let Ok(Some(v)) = row.try_get::<_, Option<i64>>(i) {
return serde_json::json!(v); return i64_to_json(v);
} }
if let Ok(Some(v)) = row.try_get::<_, Option<i16>>(i) { if let Ok(Some(v)) = row.try_get::<_, Option<i16>>(i) {
return serde_json::json!(v); return serde_json::json!(v);
@@ -1024,7 +1149,9 @@ pub async fn get_table_data(
COALESCE(fk.is_fk, false) AS is_fk, COALESCE(fk.is_fk, false) AS is_fk,
fk.foreign_table_name, fk.foreign_table_name,
fk.foreign_column_name, fk.foreign_column_name,
c.column_default c.column_default,
a.attgenerated,
a.attidentity
FROM information_schema.columns c FROM information_schema.columns c
LEFT JOIN ( LEFT JOIN (
SELECT ku.column_name, true AS is_pk SELECT ku.column_name, true AS is_pk
@@ -1056,6 +1183,11 @@ LEFT JOIN (
AND tc.table_schema = $1 AND tc.table_schema = $1
AND tc.table_name = $2 AND tc.table_name = $2
) fk ON c.column_name = fk.column_name ) fk ON c.column_name = fk.column_name
LEFT JOIN pg_attribute a
ON a.attrelid = (quote_ident(c.table_schema) || '.' || quote_ident(c.table_name))::regclass
AND a.attname = c.column_name
AND a.attnum > 0
AND NOT a.attisdropped
WHERE c.table_schema = $1 AND c.table_name = $2 WHERE c.table_schema = $1 AND c.table_name = $2
ORDER BY c.ordinal_position"#; ORDER BY c.ordinal_position"#;
let col_rows = client let col_rows = client
@@ -1068,11 +1200,21 @@ ORDER BY c.ordinal_position"#;
let is_fk: bool = r.get(4); let is_fk: bool = r.get(4);
let fk_table: Option<String> = r.get(5); let fk_table: Option<String> = r.get(5);
let fk_column: Option<String> = r.get(6); let fk_column: Option<String> = r.get(6);
// pg_attribute.attgenerated/attidentity are PG's internal
// "char" type (OID 18) → tokio-postgres delivers i8, not
// String; deserializing as String panics. Convert safely.
let attgenerated = pg_char_to_att(
r.try_get::<_, Option<i8>>(8).unwrap_or(None),
);
let attidentity = pg_char_to_att(
r.try_get::<_, Option<i8>>(9).unwrap_or(None),
);
let is_pk: bool = r.get(3);
ColumnInfo { ColumnInfo {
name: r.get(0), name: r.get(0),
data_type: r.get(1), data_type: r.get(1),
is_nullable: r.get::<_, String>(2) == "YES", is_nullable: r.get::<_, String>(2) == "YES",
is_pk: r.get(3), is_pk,
is_fk, is_fk,
fk_ref: if is_fk { fk_ref: if is_fk {
Some((fk_table.unwrap_or_default(), fk_column.unwrap_or_default())) Some((fk_table.unwrap_or_default(), fk_column.unwrap_or_default()))
@@ -1080,6 +1222,8 @@ ORDER BY c.ordinal_position"#;
None None
}, },
default_value: r.get::<_, Option<String>>(7), default_value: r.get::<_, Option<String>>(7),
editable: editable_from_att(&attgenerated, &attidentity) && !is_pk,
is_generated: !attgenerated.is_empty(),
} }
}) })
.collect(); .collect();
@@ -1098,7 +1242,8 @@ ORDER BY c.ordinal_position"#;
"timestamp without time zone", "timestamp with time zone", "timestamp without time zone", "timestamp with time zone",
"time without time zone", "time with time zone", "time without time zone", "time with time zone",
]; ];
let select_cols: Vec<String> = columns let has_pk = columns.iter().any(|c| c.is_pk);
let select_items: Vec<String> = columns
.iter() .iter()
.map(|c| { .map(|c| {
let lower = c.data_type.to_lowercase(); let lower = c.data_type.to_lowercase();
@@ -1110,10 +1255,12 @@ ORDER BY c.ordinal_position"#;
} }
}) })
.collect(); .collect();
// `ctid` is appended last for no-PK tables so later UPDATE/DELETE
// queue changes can target the exact row. It stays out of `columns`.
let data_query = format!( let data_query = format!(
"SELECT {} FROM \"{}\".\"{}\" WHERE 1=1{} {} LIMIT {} OFFSET {}", "{} WHERE 1=1{} {} LIMIT {} OFFSET {}",
select_cols.join(", "), build_pg_select_from_items(&schema, &table, select_items, has_pk),
schema, table, filter_clause, order_clause, ps, off filter_clause, order_clause, ps, off
); );
let data_rows = if filter_params.is_empty() { let data_rows = if filter_params.is_empty() {
client client
@@ -1207,14 +1354,21 @@ ORDER BY c.ordinal_position"#;
is_fk: fk.is_some(), is_fk: fk.is_some(),
fk_ref: fk.map(|(t, c)| (t.clone(), c.clone())), fk_ref: fk.map(|(t, c)| (t.clone(), c.clone())),
default_value: default_val.clone(), default_value: default_val.clone(),
editable: !*is_pk,
is_generated: false,
} }
}) })
.collect(); .collect();
// Get data (with filters and sorts applied) // Get data (with filters and sorts applied).
// `rowid` is appended last for no-PK tables so later UPDATE/DELETE
// queue changes can target the exact row. It stays out of `columns`.
let visible_names: Vec<String> = columns.iter().map(|c| c.name.clone()).collect();
let has_pk = columns.iter().any(|c| c.is_pk);
let data_query = format!( let data_query = format!(
"SELECT * FROM \"{}\".\"{}\" WHERE 1=1{} {} LIMIT {} OFFSET {}", "{} WHERE 1=1{} {} LIMIT {} OFFSET {}",
schema, table, filter_clause, order_clause, ps, off build_sqlite_data_select(&table, &visible_names, has_pk),
filter_clause, order_clause, ps, off
); );
let mut stmt = conn.prepare(&data_query).map_err(|e| e.to_string())?; let mut stmt = conn.prepare(&data_query).map_err(|e| e.to_string())?;
let col_count = stmt.column_count(); let col_count = stmt.column_count();
@@ -1334,6 +1488,8 @@ ORDER BY c.ordinal_position"#;
None None
}, },
default_value: r.get::<_, Option<String>>(7), default_value: r.get::<_, Option<String>>(7),
editable: true,
is_generated: false,
} }
}) })
.collect(); .collect();
@@ -1411,6 +1567,8 @@ ORDER BY c.ordinal_position"#;
is_fk: fk.is_some(), is_fk: fk.is_some(),
fk_ref: fk.map(|(t, c)| (t.clone(), c.clone())), fk_ref: fk.map(|(t, c)| (t.clone(), c.clone())),
default_value: default_val.clone(), default_value: default_val.clone(),
editable: true,
is_generated: false,
} }
}) })
.collect(); .collect();
@@ -1447,6 +1605,16 @@ ORDER BY c.ordinal_position"#;
} }
} }
/// Map a tokio_postgres/rusqlite affected-row count to a friendly error.
/// Exactly 1 -> Ok (None). 0 -> stale; >1 -> ambiguous.
pub(crate) fn affected_count_error(n: u64) -> Option<String> {
match n {
0 => Some("row was modified or removed by another session".to_string()),
1 => None,
_ => Some("ambiguous row match".to_string()),
}
}
#[tauri::command] #[tauri::command]
pub async fn execute_change( pub async fn execute_change(
connection_id: String, connection_id: String,
@@ -1467,7 +1635,7 @@ pub async fn execute_change(
} => { } => {
let pk = parse_json_pairs(primary_key)?; let pk = parse_json_pairs(primary_key)?;
let data = parse_json_pairs(new_data)?; let data = parse_json_pairs(new_data)?;
build_pg_update_sql(schema, table, &pk, &data) build_pg_update_sql(schema, table, &pk, &data)?
} }
Change::Insert { Change::Insert {
schema, schema,
@@ -1485,7 +1653,7 @@ pub async fn execute_change(
.. ..
} => { } => {
let pk = parse_json_pairs(primary_key)?; let pk = parse_json_pairs(primary_key)?;
build_pg_delete_sql(schema, table, &pk) build_pg_delete_sql(schema, table, &pk)?
} }
Change::AlterTable { sql, .. } => { Change::AlterTable { sql, .. } => {
// Execute the raw DDL directly; no bound parameters. // Execute the raw DDL directly; no bound parameters.
@@ -1536,7 +1704,13 @@ pub async fn execute_change(
r r
}) })
.collect(); .collect();
client.execute(&sql, &refs).await.map_err(|e| e.to_string())?; let n = client
.execute(&sql, &refs)
.await
.map_err(|e| e.to_string())?;
if let Some(msg) = affected_count_error(n) {
return Err(msg);
}
Ok(()) Ok(())
} }
Some(crate::db::pool::DbHandle::Sqlite(conn)) => { Some(crate::db::pool::DbHandle::Sqlite(conn)) => {
@@ -1550,13 +1724,7 @@ pub async fn execute_change(
} => { } => {
let pk = parse_json_pairs(primary_key)?; let pk = parse_json_pairs(primary_key)?;
let data = parse_json_pairs(new_data)?; let data = parse_json_pairs(new_data)?;
( build_update_sql(schema, table, &pk, &data)?
build_update_sql(schema, table, &pk, &data),
pk.iter()
.chain(data.iter())
.map(|(_, v)| v.clone())
.collect(),
)
} }
Change::Insert { Change::Insert {
schema, schema,
@@ -1579,10 +1747,7 @@ pub async fn execute_change(
.. ..
} => { } => {
let pk = parse_json_pairs(primary_key)?; let pk = parse_json_pairs(primary_key)?;
( build_delete_sql(schema, table, &pk)?
build_delete_sql(schema, table, &pk),
pk.iter().map(|(_, v)| v.clone()).collect(),
)
} }
Change::AlterTable { sql, .. } => { Change::AlterTable { sql, .. } => {
conn.execute(sql, []).map_err(|e| e.to_string())?; conn.execute(sql, []).map_err(|e| e.to_string())?;
@@ -1612,8 +1777,12 @@ pub async fn execute_change(
let sqlite_params: Vec<rusqlite::types::Value> = let sqlite_params: Vec<rusqlite::types::Value> =
params.iter().map(json_to_sqlite_value).collect(); params.iter().map(json_to_sqlite_value).collect();
conn.execute(&sql, rusqlite::params_from_iter(sqlite_params)) let n = conn
.execute(&sql, rusqlite::params_from_iter(sqlite_params))
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
if let Some(msg) = affected_count_error(n as u64) {
return Err(msg);
}
Ok(()) Ok(())
} }
None => Err("Connection not found".to_string()), None => Err("Connection not found".to_string()),
@@ -1678,6 +1847,85 @@ pub async fn get_functions(
} }
} }
#[tauri::command]
pub async fn get_indexes(
connection_id: String,
schema: Option<String>,
state: State<'_, crate::AppState>,
) -> Result<Vec<IndexInfo>, String> {
let mut pm = state.pool_manager.lock().await;
match pm.get(&connection_id) {
Some(DbHandle::Postgresql(client, _)) => {
let schema = schema.unwrap_or_else(|| "public".to_string());
let query = crate::db::introspection::pg_indexes_query(&schema);
let rows = client
.query(&query, &[&schema])
.await
.map_err(|e| e.to_string())?;
Ok(rows
.iter()
.map(|r| IndexInfo {
name: r.get(0),
schema: r.get(1),
table: r.get(2),
definition: r.get(3),
is_unique: r.get(4),
method: r.get::<_, Option<String>>(5).unwrap_or_default(),
columns: split_columns_csv(&r.get::<_, Option<String>>(6).unwrap_or_default()),
size_bytes: r.get::<_, Option<i64>>(7),
tablespace: r.get::<_, Option<String>>(8),
})
.collect())
}
Some(DbHandle::Sqlite(_)) => Ok(vec![]),
None => Err("Connection not found".into()),
}
}
#[tauri::command]
pub async fn get_constraints(
connection_id: String,
schema: Option<String>,
state: State<'_, crate::AppState>,
) -> Result<Vec<ConstraintInfo>, String> {
let mut pm = state.pool_manager.lock().await;
match pm.get(&connection_id) {
Some(DbHandle::Postgresql(client, _)) => {
let schema = schema.unwrap_or_else(|| "public".to_string());
let query = crate::db::introspection::pg_constraints_query(&schema);
let rows = client
.query(&query, &[&schema])
.await
.map_err(|e| e.to_string())?;
Ok(rows
.iter()
.map(|r| {
// contype::text decodes as a String ("c" | "u" | "x").
let contype = match r.get::<_, Option<String>>(3).unwrap_or_default().as_str() {
"c" => "CHECK",
"u" => "UNIQUE",
"x" => "EXCLUSION",
other => other,
}
.to_string();
ConstraintInfo {
name: r.get(0),
schema: r.get(1),
table: r.get(2),
contype,
definition: r.get(4),
deferrable: r.get(5),
validated: r.get(6),
columns: split_columns_csv(&r.get::<_, Option<String>>(7).unwrap_or_default()),
}
})
.collect())
}
Some(DbHandle::Sqlite(_)) => Ok(vec![]),
None => Err("Connection not found".into()),
}
}
#[tauri::command] #[tauri::command]
pub async fn get_triggers( pub async fn get_triggers(
connection_id: String, connection_id: String,
@@ -1877,6 +2125,28 @@ mod tests {
use super::*; use super::*;
use crate::models::db_viewer::Change; use crate::models::db_viewer::Change;
#[test]
fn split_columns_csv_handles_commas_and_trims() {
assert_eq!(split_columns_csv("id, name, created_at"), vec!["id", "name", "created_at"]);
assert_eq!(split_columns_csv("id"), vec!["id"]);
assert_eq!(split_columns_csv(""), Vec::<String>::new());
// expression index column list may include parens — keep raw, just split on top-level commas
assert_eq!(split_columns_csv("lower(name), id"), vec!["lower(name)", "id"]);
}
/// bigint precision: values beyond 2^53 must round-trip as strings.
#[test]
fn i64_preserves_precision_as_string() {
// A bigint beyond 2^53 must round-trip as a string, not a JS number.
let big: i64 = 9_007_199_254_740_993; // 2^53 + 1
let v = i64_to_json(big);
assert_eq!(v, serde_json::Value::String("9007199254740993".to_string()),
"bigint must be a string to avoid float precision loss");
let small: i64 = 42;
let v2 = i64_to_json(small);
assert_eq!(v2, serde_json::Value::String("42".to_string()));
}
/// Verify the `offset` helper produces correct pagination offsets. /// Verify the `offset` helper produces correct pagination offsets.
#[test] #[test]
fn pagination_offset_is_correct() { fn pagination_offset_is_correct() {
@@ -1930,7 +2200,7 @@ mod tests {
("email".to_string(), serde_json::json!("bob@example.com")), ("email".to_string(), serde_json::json!("bob@example.com")),
]; ];
let sql = build_update_sql("public", "users", &pk, &data); let (sql, _params) = build_update_sql("public", "users", &pk, &data).unwrap();
assert!( assert!(
sql.to_uppercase().contains("UPDATE"), sql.to_uppercase().contains("UPDATE"),
@@ -1955,7 +2225,7 @@ mod tests {
fn build_change_delete_sql_is_valid() { fn build_change_delete_sql_is_valid() {
let pk = vec![("id".to_string(), serde_json::json!(1))]; let pk = vec![("id".to_string(), serde_json::json!(1))];
let sql = build_delete_sql("public", "users", &pk); let (sql, _params) = build_delete_sql("public", "users", &pk).unwrap();
assert!( assert!(
sql.to_uppercase().contains("DELETE FROM"), sql.to_uppercase().contains("DELETE FROM"),
@@ -2116,4 +2386,90 @@ mod tests {
r#"DELETE FROM "public"."users""# r#"DELETE FROM "public"."users""#
); );
} }
// -----------------------------------------------------------------------
// Row-locator / editability helpers (Task 7)
// -----------------------------------------------------------------------
#[test]
fn editable_pg_column_flags_mark_generated_and_identity_always() {
// generated STORED ('s') -> not editable; identity ALWAYS ('a') -> not editable
assert!(!editable_from_att("s", ""));
assert!(!editable_from_att("", "a"));
// plain column -> editable
assert!(editable_from_att("", ""));
// identity BY DEFAULT ('d') -> editable
assert!(editable_from_att("", "d"));
}
#[test]
fn pg_char_to_att_maps_internal_char_codes_safely() {
// pg_attribute "char" arrives as i8; None/\0 -> "", codes -> 1-char string
assert_eq!(pg_char_to_att(None), "");
assert_eq!(pg_char_to_att(Some(0)), "");
assert_eq!(pg_char_to_att(Some(b's' as i8)), "s");
assert_eq!(pg_char_to_att(Some(b'v' as i8)), "v");
assert_eq!(pg_char_to_att(Some(b'a' as i8)), "a");
assert_eq!(pg_char_to_att(Some(b'd' as i8)), "d");
// wiring: a STORED generated column must be non-editable through the helper
assert!(!editable_from_att(&pg_char_to_att(Some(b's' as i8)), ""));
}
#[test]
fn pg_locator_select_adds_ctid() {
let sql = build_pg_data_select("public", "no_pk", &["id".into(), "name".into()], false);
assert!(sql.contains("ctid"), "no-PK table must select ctid; got: {}", sql);
assert!(sql.contains("\"public\""), "schema must be quoted; got: {}", sql);
}
#[test]
fn pg_locator_select_omits_ctid_when_pk_present() {
let sql = build_pg_data_select("public", "with_pk", &["id".into(), "name".into()], true);
assert!(!sql.contains("ctid"), "PK table must NOT select ctid; got: {}", sql);
}
#[test]
fn sqlite_locator_select_adds_rowid_for_no_pk() {
let sql = build_sqlite_data_select("no_pk", &["id".into(), "name".into()], false);
assert!(sql.contains("rowid"), "no-PK sqlite table must select rowid; got: {}", sql);
}
// -----------------------------------------------------------------------
// No-PK row locator updates + affected-row-count guard (Task 8)
// -----------------------------------------------------------------------
#[test]
fn pg_update_with_locator_uses_it_in_where() {
// The frontend supplies ctid as the "primary_key" pair for no-PK rows.
let locator = vec![("ctid".to_string(), serde_json::json!("(0,1)"))];
let data = vec![("name".to_string(), serde_json::json!("Bob"))];
let (sql, params) = build_pg_update_sql("public", "no_pk", &locator, &data).unwrap();
assert!(sql.contains("\"ctid\" = $"), "locator update must WHERE on ctid; got: {}", sql);
assert_eq!(params.len(), 2); // 1 SET value + 1 WHERE value
}
#[test]
fn pg_update_with_pk_uses_pk_where() {
let pk = vec![("id".to_string(), serde_json::json!(1))];
let data = vec![("name".to_string(), serde_json::json!("Bob"))];
let (sql, _params) = build_pg_update_sql("public", "users", &pk, &data).unwrap();
assert!(sql.contains("\"id\" = $"), "PK update must WHERE on id; got: {}", sql);
assert!(!sql.contains("ctid"), "PK update must NOT use ctid; got: {}", sql);
}
#[test]
fn pg_update_with_empty_primary_key_is_rejected() {
// Defense-in-depth: an empty locator must NOT yield `UPDATE ... WHERE `.
let pk: Vec<(String, serde_json::Value)> = vec![];
let data = vec![("name".to_string(), serde_json::json!("Bob"))];
let result = build_pg_update_sql("public", "no_pk", &pk, &data);
assert!(result.is_err(), "empty primary_key must be rejected, not produce broken SQL");
}
#[test]
fn affected_row_count_message_for_zero_rows() {
assert_eq!(affected_count_error(0u64), Some("row was modified or removed by another session".to_string()));
assert_eq!(affected_count_error(1u64), None);
assert_eq!(affected_count_error(2u64), Some("ambiguous row match".to_string()));
}
} }
+6
View File
@@ -243,6 +243,8 @@ async fn execute_pg_query(
is_fk: false, is_fk: false,
fk_ref: None, fk_ref: None,
default_value: None, default_value: None,
editable: true,
is_generated: false,
}) })
.collect(), .collect(),
None => { None => {
@@ -309,6 +311,8 @@ async fn execute_pg_raw(
is_fk: false, is_fk: false,
fk_ref: None, fk_ref: None,
default_value: None, default_value: None,
editable: true,
is_generated: false,
}) })
.collect(); .collect();
saw_columns = true; saw_columns = true;
@@ -432,6 +436,8 @@ fn execute_sqlite_with_query(
is_fk: false, is_fk: false,
fk_ref: None, fk_ref: None,
default_value: None, default_value: None,
editable: true,
is_generated: false,
} }
}) })
.collect(); .collect();
+139 -16
View File
@@ -13,6 +13,10 @@ pub struct TestConnectionResult {
pub ok: bool, pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>, pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub server_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub latency_ms: Option<u64>,
} }
/// Strip credentials and sensitive information from error messages while /// Strip credentials and sensitive information from error messages while
@@ -212,6 +216,8 @@ pub async fn test_database_connection(
return TestConnectionResult { return TestConnectionResult {
ok: false, ok: false,
error: Some(err), error: Some(err),
server_version: None,
latency_ms: None,
}; };
} }
@@ -223,12 +229,16 @@ pub async fn test_database_connection(
other => TestConnectionResult { other => TestConnectionResult {
ok: false, ok: false,
error: Some(format!("unsupported database type: {other}")), error: Some(format!("unsupported database type: {other}")),
server_version: None,
latency_ms: None,
}, },
}; };
TestConnectionResult { TestConnectionResult {
ok: result.ok, ok: result.ok,
error: result.error.map(|e| sanitize_error(&e)), error: result.error.map(|e| sanitize_error(&e)),
server_version: result.server_version,
latency_ms: result.latency_ms,
} }
} }
@@ -245,7 +255,14 @@ async fn test_pg_connection(config: &DbConfig, ssh: &SshManager) -> TestConnecti
let target = match resolve_connect_target(config, ssh, 5432).await { let target = match resolve_connect_target(config, ssh, 5432).await {
Ok(t) => t, Ok(t) => t,
Err(e) => return TestConnectionResult { ok: false, error: Some(e) }, Err(e) => {
return TestConnectionResult {
ok: false,
error: Some(e),
server_version: None,
latency_ms: None,
}
}
}; };
// TLS: through a tunnel the peer is loopback, so verify-ca/verify-full // TLS: through a tunnel the peer is loopback, so verify-ca/verify-full
@@ -263,7 +280,12 @@ async fn test_pg_connection(config: &DbConfig, ssh: &SshManager) -> TestConnecti
Ok(t) => t, Ok(t) => t,
Err(e) => { Err(e) => {
close_probe_tunnel(ssh, target.tunnel_key.as_deref()); close_probe_tunnel(ssh, target.tunnel_key.as_deref());
return TestConnectionResult { ok: false, error: Some(e) }; return TestConnectionResult {
ok: false,
error: Some(e),
server_version: None,
latency_ms: None,
};
} }
}; };
@@ -278,6 +300,7 @@ async fn test_pg_connection(config: &DbConfig, ssh: &SshManager) -> TestConnecti
.dbname(dbname) .dbname(dbname)
.connect_timeout(std::time::Duration::from_secs(10)); .connect_timeout(std::time::Duration::from_secs(10));
let start = std::time::Instant::now();
let result = match tls { let result = match tls {
None => crate::commands::db_viewer::connect_pg_with(&pgconfig, tokio_postgres::NoTls).await, None => crate::commands::db_viewer::connect_pg_with(&pgconfig, tokio_postgres::NoTls).await,
Some(cc) => { Some(cc) => {
@@ -286,19 +309,33 @@ async fn test_pg_connection(config: &DbConfig, ssh: &SshManager) -> TestConnecti
crate::commands::db_viewer::connect_pg_with(&pgconfig, connector).await crate::commands::db_viewer::connect_pg_with(&pgconfig, connector).await
} }
}; };
let latency_ms = Some(start.elapsed().as_millis() as u64);
match result { match result {
Ok((_client, _handle)) => { Ok((client, _handle)) => {
close_probe_tunnel(ssh, target.tunnel_key.as_deref()); close_probe_tunnel(ssh, target.tunnel_key.as_deref());
// Spawn the connection handler so it keeps running while we test. // Best-effort server version from the live client; None if the
// (Already spawned inside `connect_pg_with`.) // query fails. The driver task is already spawned inside
TestConnectionResult { ok: true, error: None } // `connect_pg_with`, so the client is fully usable here.
let server_version = client
.query_one("SELECT current_setting('server_version')", &[])
.await
.ok()
.and_then(|row| row.try_get::<_, String>(0).ok());
TestConnectionResult {
ok: true,
error: None,
server_version,
latency_ms,
}
} }
Err(e) => { Err(e) => {
close_probe_tunnel(ssh, target.tunnel_key.as_deref()); close_probe_tunnel(ssh, target.tunnel_key.as_deref());
TestConnectionResult { TestConnectionResult {
ok: false, ok: false,
error: Some(e.to_string()), error: Some(e.to_string()),
server_version: None,
latency_ms: None,
} }
} }
} }
@@ -315,7 +352,14 @@ async fn test_mysql_connection(config: &DbConfig, ssh: &SshManager) -> TestConne
let target = match resolve_connect_target(config, ssh, 3306).await { let target = match resolve_connect_target(config, ssh, 3306).await {
Ok(t) => t, Ok(t) => t,
Err(e) => return TestConnectionResult { ok: false, error: Some(e) }, Err(e) => {
return TestConnectionResult {
ok: false,
error: Some(e),
server_version: None,
latency_ms: None,
}
}
}; };
let mut opts = MySqlConnectOptions::new() let mut opts = MySqlConnectOptions::new()
@@ -351,6 +395,7 @@ async fn test_mysql_connection(config: &DbConfig, ssh: &SshManager) -> TestConne
} }
} }
let start = std::time::Instant::now();
match MySqlPoolOptions::new() match MySqlPoolOptions::new()
.max_connections(1) .max_connections(1)
.acquire_timeout(std::time::Duration::from_secs(10)) .acquire_timeout(std::time::Duration::from_secs(10))
@@ -359,14 +404,28 @@ async fn test_mysql_connection(config: &DbConfig, ssh: &SshManager) -> TestConne
{ {
Ok(pool) => { Ok(pool) => {
close_probe_tunnel(ssh, target.tunnel_key.as_deref()); close_probe_tunnel(ssh, target.tunnel_key.as_deref());
// Best-effort server version; None if the query fails.
let server_version =
sqlx::query_scalar::<_, String>("SELECT VERSION()")
.fetch_one(&pool)
.await
.ok();
let latency_ms = Some(start.elapsed().as_millis() as u64);
pool.close().await; pool.close().await;
TestConnectionResult { ok: true, error: None } TestConnectionResult {
ok: true,
error: None,
server_version,
latency_ms,
}
} }
Err(e) => { Err(e) => {
close_probe_tunnel(ssh, target.tunnel_key.as_deref()); close_probe_tunnel(ssh, target.tunnel_key.as_deref());
TestConnectionResult { TestConnectionResult {
ok: false, ok: false,
error: Some(e.to_string()), error: Some(e.to_string()),
server_version: None,
latency_ms: None,
} }
} }
} }
@@ -377,11 +436,27 @@ async fn test_mysql_connection(config: &DbConfig, ssh: &SshManager) -> TestConne
/// Opens the database file at `config.host`. Returns success if the file /// Opens the database file at `config.host`. Returns success if the file
/// can be opened as a valid SQLite database. /// can be opened as a valid SQLite database.
fn test_sqlite_connection(config: &DbConfig) -> TestConnectionResult { fn test_sqlite_connection(config: &DbConfig) -> TestConnectionResult {
let start = std::time::Instant::now();
match rusqlite::Connection::open(&config.host) { match rusqlite::Connection::open(&config.host) {
Ok(_conn) => TestConnectionResult { ok: true, error: None }, Ok(conn) => {
// Best-effort server version; None if the query fails.
let server_version = conn
.query_row("SELECT sqlite_version()", [], |r| {
r.get::<_, String>(0)
})
.ok();
TestConnectionResult {
ok: true,
error: None,
server_version,
latency_ms: Some(start.elapsed().as_millis() as u64),
}
}
Err(e) => TestConnectionResult { Err(e) => TestConnectionResult {
ok: false, ok: false,
error: Some(e.to_string()), error: Some(e.to_string()),
server_version: None,
latency_ms: None,
}, },
} }
} }
@@ -396,7 +471,14 @@ async fn test_redis_connection(config: &DbConfig, ssh: &SshManager) -> TestConne
let target = match resolve_connect_target(config, ssh, 6379).await { let target = match resolve_connect_target(config, ssh, 6379).await {
Ok(t) => t, Ok(t) => t,
Err(e) => return TestConnectionResult { ok: false, error: Some(e) }, Err(e) => {
return TestConnectionResult {
ok: false,
error: Some(e),
server_version: None,
latency_ms: None,
}
}
}; };
let password = config.password.as_deref(); let password = config.password.as_deref();
@@ -406,6 +488,7 @@ async fn test_redis_connection(config: &DbConfig, ssh: &SshManager) -> TestConne
format!("redis://{}:{}/", target.host, target.port) format!("redis://{}:{}/", target.host, target.port)
}; };
let start = std::time::Instant::now();
match redis::Client::open(conn_str.as_str()) { match redis::Client::open(conn_str.as_str()) {
Ok(client) => { Ok(client) => {
match timeout( match timeout(
@@ -416,13 +499,20 @@ async fn test_redis_connection(config: &DbConfig, ssh: &SshManager) -> TestConne
{ {
Ok(Ok(_conn)) => { Ok(Ok(_conn)) => {
close_probe_tunnel(ssh, target.tunnel_key.as_deref()); close_probe_tunnel(ssh, target.tunnel_key.as_deref());
TestConnectionResult { ok: true, error: None } TestConnectionResult {
ok: true,
error: None,
server_version: None,
latency_ms: Some(start.elapsed().as_millis() as u64),
}
} }
Ok(Err(e)) => { Ok(Err(e)) => {
close_probe_tunnel(ssh, target.tunnel_key.as_deref()); close_probe_tunnel(ssh, target.tunnel_key.as_deref());
TestConnectionResult { TestConnectionResult {
ok: false, ok: false,
error: Some(e.to_string()), error: Some(e.to_string()),
server_version: None,
latency_ms: None,
} }
} }
Err(_) => { Err(_) => {
@@ -430,6 +520,8 @@ async fn test_redis_connection(config: &DbConfig, ssh: &SshManager) -> TestConne
TestConnectionResult { TestConnectionResult {
ok: false, ok: false,
error: Some("connection timed out after 10 seconds".to_string()), error: Some("connection timed out after 10 seconds".to_string()),
server_version: None,
latency_ms: None,
} }
} }
} }
@@ -439,6 +531,8 @@ async fn test_redis_connection(config: &DbConfig, ssh: &SshManager) -> TestConne
TestConnectionResult { TestConnectionResult {
ok: false, ok: false,
error: Some(e.to_string()), error: Some(e.to_string()),
server_version: None,
latency_ms: None,
} }
} }
} }
@@ -466,18 +560,47 @@ mod tests {
#[test] #[test]
fn test_connection_result_serialization() { fn test_connection_result_serialization() {
// ok=true result serializes correctly // ok=true result with server_version/latency serializes all fields
let result = TestConnectionResult { ok: true, error: None }; let result = TestConnectionResult {
ok: true,
error: None,
server_version: Some("15.2".to_string()),
latency_ms: Some(12),
};
let json = serde_json::to_string(&result).unwrap(); let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"ok\":true"), "ok=true should appear in JSON"); assert!(
json.contains("\"ok\":true"),
"ok=true should appear in JSON: {json}"
);
assert!(
json.contains("\"server_version\":\"15.2\""),
"server_version should appear in JSON: {json}"
);
assert!(
json.contains("\"latency_ms\":12"),
"latency_ms should appear in JSON: {json}"
);
// error result includes the error message // error result includes the error message; None fields are skipped
let result = TestConnectionResult { let result = TestConnectionResult {
ok: false, ok: false,
error: Some("connection refused".to_string()), error: Some("connection refused".to_string()),
server_version: None,
latency_ms: None,
}; };
let json = serde_json::to_string(&result).unwrap(); let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"connection refused\""), "error message should appear in JSON"); assert!(
json.contains("\"connection refused\""),
"error message should appear in JSON: {json}"
);
assert!(
!json.contains("server_version"),
"None server_version should be skipped: {json}"
);
assert!(
!json.contains("latency_ms"),
"None latency_ms should be skipped: {json}"
);
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
+107 -2
View File
@@ -26,12 +26,19 @@ pub fn pg_tables_query(schema: Option<&str>) -> String {
match schema { match schema {
Some(s) => format!( Some(s) => format!(
"SELECT table_name, table_type FROM information_schema.tables \ "SELECT table_name, table_type FROM information_schema.tables \
WHERE table_schema = '{}' ORDER BY table_name", WHERE table_schema = '{}' \
s UNION ALL \
SELECT matviewname AS table_name, 'MATERIALIZED VIEW' AS table_type \
FROM pg_matviews WHERE schemaname = '{}' \
ORDER BY table_name",
s, s
), ),
None => { None => {
"SELECT table_name, table_type, table_schema FROM information_schema.tables \ "SELECT table_name, table_type, table_schema FROM information_schema.tables \
WHERE table_schema NOT IN ('pg_catalog', 'information_schema') \ WHERE table_schema NOT IN ('pg_catalog', 'information_schema') \
UNION ALL \
SELECT matviewname AS table_name, 'MATERIALIZED VIEW' AS table_type, schemaname AS table_schema \
FROM pg_matviews WHERE schemaname NOT IN ('pg_catalog', 'information_schema') \
ORDER BY table_schema, table_name" ORDER BY table_schema, table_name"
.to_string() .to_string()
} }
@@ -285,6 +292,61 @@ pub fn pg_extensions_query() -> String {
.to_string() .to_string()
} }
/// Query indexes in a schema.
///
/// Returns index name, schema, table, definition (`pg_get_indexdef`),
/// uniqueness, access method, columns CSV, size in bytes, and tablespace.
pub fn pg_indexes_query(_schema: &str) -> String {
format!(
"SELECT \
i.relname AS index_name, \
ns.nspname AS schema, \
t.relname AS table_name, \
pg_get_indexdef(ix.indexrelid) AS definition, \
ix.indisunique AS is_unique, \
am.amname AS method, \
(SELECT string_agg(a.attname, ', ' ORDER BY ord.ord) \
FROM unnest(ix.indkey) WITH ORDINALITY AS ord(attnum, ord) \
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ord.attnum) AS columns, \
pg_relation_size(i.oid) AS size_bytes, \
ts.spcname AS tablespace \
FROM pg_index ix \
JOIN pg_class i ON i.oid = ix.indexrelid \
JOIN pg_class t ON t.oid = ix.indrelid \
JOIN pg_namespace ns ON t.relnamespace = ns.oid \
JOIN pg_am am ON i.relam = am.oid \
LEFT JOIN pg_tablespace ts ON i.reltablespace = ts.oid \
WHERE ns.nspname = $1 \
ORDER BY i.relname"
)
}
/// Query CHECK / UNIQUE / EXCLUSION constraints in a schema.
///
/// Primary and foreign keys are intentionally excluded — they surface in the
/// table grid. Returns name, schema, table, contype, definition
/// (`pg_get_constraintdef`), deferrability, validation, and columns CSV.
pub fn pg_constraints_query(_schema: &str) -> String {
format!(
"SELECT \
c.conname AS name, \
ns.nspname AS schema, \
cl.relname AS table_name, \
c.contype::text, \
pg_get_constraintdef(c.oid) AS definition, \
c.condeferrable, \
c.convalidated, \
(SELECT string_agg(a.attname, ', ' ORDER BY ord.ord) \
FROM unnest(c.conkey) WITH ORDINALITY AS ord(attnum, ord) \
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ord.attnum) AS columns \
FROM pg_constraint c \
JOIN pg_class cl ON c.conrelid = cl.oid \
JOIN pg_namespace ns ON cl.relnamespace = ns.oid \
WHERE ns.nspname = $1 AND c.contype IN ('c', 'u', 'x') \
ORDER BY c.conname"
)
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Tests // Tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -365,6 +427,49 @@ mod tests {
assert!(sql.contains("datistemplate")); assert!(sql.contains("datistemplate"));
} }
#[test]
fn pg_indexes_query_is_parameterized_and_joins() {
let sql = pg_indexes_query("public");
assert!(sql.contains("$1"), "schema must be parameterized; got: {}", sql);
assert!(
sql.contains("pg_indexes") || sql.contains("pg_index"),
"should query pg_index; got: {}",
sql
);
assert!(sql.contains("pg_get_indexdef"), "should include index definition");
assert!(sql.contains("indisunique"), "should include uniqueness");
}
#[test]
fn pg_constraints_query_filters_check_unique_exclusion() {
let sql = pg_constraints_query("public");
assert!(sql.contains("$1"), "schema must be parameterized; got: {}", sql);
assert!(
sql.contains("pg_constraint"),
"should query pg_constraint; got: {}",
sql
);
assert!(sql.contains("contype"), "should select contype");
assert!(sql.contains("'c'"), "should filter CHECK ('c')");
assert!(sql.contains("'u'"), "should filter UNIQUE ('u')");
assert!(sql.contains("'x'"), "should filter EXCLUSION ('x')");
assert!(sql.contains("pg_get_constraintdef"), "should include definition");
}
#[test]
fn pg_tables_query_includes_materialized_views() {
let sql = pg_tables_query(Some("public"));
assert!(
sql.contains("pg_matviews"),
"matview UNION must source pg_matviews; got: {}",
sql,
);
assert!(
sql.contains("MATERIALIZED VIEW"),
"should label materialized views"
);
}
// --------------------------------------------------------------- // ---------------------------------------------------------------
// MySQL // MySQL
// --------------------------------------------------------------- // ---------------------------------------------------------------
+6
View File
@@ -78,6 +78,10 @@ pub fn run() {
connections::update_connection, connections::update_connection,
connections::delete_connection, connections::delete_connection,
connections::add_connection_tags, connections::add_connection_tags,
connections::set_connection_favorite,
connections::record_recent_connection,
connections::get_recent_connections,
connections::clear_recent_connections,
folders::get_folders, folders::get_folders,
folders::create_folder, folders::create_folder,
folders::delete_folder, folders::delete_folder,
@@ -107,6 +111,8 @@ pub fn run() {
db_viewer::get_sequences, db_viewer::get_sequences,
db_viewer::get_enums, db_viewer::get_enums,
db_viewer::get_extensions, db_viewer::get_extensions,
db_viewer::get_indexes,
db_viewer::get_constraints,
keychain::save_connection_password, keychain::save_connection_password,
keychain::get_connection_password, keychain::get_connection_password,
keychain::delete_connection_password, keychain::delete_connection_password,
+18
View File
@@ -12,6 +12,7 @@ pub struct Connection {
pub folder_id: Option<String>, pub folder_id: Option<String>,
pub keychain_ref: Option<String>, pub keychain_ref: Option<String>,
pub environment: Option<String>, pub environment: Option<String>,
pub favorite: bool,
pub ssh_host: Option<String>, pub ssh_host: Option<String>,
pub ssh_port: Option<i64>, pub ssh_port: Option<i64>,
pub ssh_user: Option<String>, pub ssh_user: Option<String>,
@@ -118,6 +119,7 @@ mod tests {
folder_id: Some("folder".to_string()), folder_id: Some("folder".to_string()),
keychain_ref: Some("keychain-ref".to_string()), keychain_ref: Some("keychain-ref".to_string()),
environment: None, environment: None,
favorite: false,
tag_ids: vec![], tag_ids: vec![],
created_at: "2024-01-01T00:00:00Z".to_string(), created_at: "2024-01-01T00:00:00Z".to_string(),
updated_at: "2024-01-01T00:00:00Z".to_string(), updated_at: "2024-01-01T00:00:00Z".to_string(),
@@ -136,4 +138,20 @@ mod tests {
let json = serde_json::to_string(&conn).unwrap(); let json = serde_json::to_string(&conn).unwrap();
assert!(!json.contains("password"), "Connection JSON should not contain password field"); assert!(!json.contains("password"), "Connection JSON should not contain password field");
} }
#[test]
fn connection_serializes_favorite_field() {
let conn = Connection {
id: "x".into(), name: "n".into(), db_type: "postgresql".into(),
host: "h".into(), port: Some(5432), username: None, database: None,
folder_id: None, keychain_ref: None, environment: None,
ssh_host: None, ssh_port: None, ssh_user: None, ssh_auth_method: None,
ssh_private_key_path: None, ssl_mode: None, ssl_ca_path: None,
ssl_cert_path: None, ssl_key_path: None, tag_ids: vec![],
favorite: true,
created_at: "2024-01-01T00:00:00Z".into(), updated_at: "2024-01-01T00:00:00Z".into(),
};
let json = serde_json::to_string(&conn).unwrap();
assert!(json.contains("\"favorite\":true"));
}
} }
+41
View File
@@ -33,6 +33,33 @@ pub struct ColumnInfo {
pub is_fk: bool, pub is_fk: bool,
pub fk_ref: Option<(String, String)>, pub fk_ref: Option<(String, String)>,
pub default_value: Option<String>, pub default_value: Option<String>,
pub editable: bool,
pub is_generated: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IndexInfo {
pub name: String,
pub schema: String,
pub table: String,
pub definition: String,
pub is_unique: bool,
pub method: String,
pub columns: Vec<String>,
pub size_bytes: Option<i64>,
pub tablespace: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConstraintInfo {
pub name: String,
pub schema: String,
pub table: String,
pub contype: String, // "CHECK" | "UNIQUE" | "EXCLUSION"
pub definition: String,
pub deferrable: bool,
pub validated: bool,
pub columns: Vec<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -338,12 +365,26 @@ mod tests {
is_fk: true, is_fk: true,
fk_ref: Some(("users".to_string(), "id".to_string())), fk_ref: Some(("users".to_string(), "id".to_string())),
default_value: None, default_value: None,
editable: true,
is_generated: false,
}; };
let json = serde_json::to_string(&col).unwrap(); let json = serde_json::to_string(&col).unwrap();
assert!(json.contains("user_id")); assert!(json.contains("user_id"));
assert!(json.contains("users")); assert!(json.contains("users"));
} }
#[test]
fn column_info_has_editability_fields() {
let c = ColumnInfo {
name: "id".into(), data_type: "integer".into(), is_nullable: false,
is_pk: true, is_fk: false, fk_ref: None, default_value: None,
editable: false, is_generated: false,
};
let json = serde_json::to_string(&c).unwrap();
assert!(json.contains("\"editable\":false"));
assert!(json.contains("\"is_generated\":false"));
}
#[test] #[test]
fn pagination_serialization() { fn pagination_serialization() {
let pagination = Pagination { let pagination = Pagination {
+2
View File
@@ -2,11 +2,13 @@ pub mod backup;
pub mod connection; pub mod connection;
pub mod db_viewer; pub mod db_viewer;
pub mod folder; pub mod folder;
pub mod recent;
pub mod ssh; pub mod ssh;
pub mod tag; pub mod tag;
pub mod settings; pub mod settings;
pub use connection::{Connection, ConnectionInput}; pub use connection::{Connection, ConnectionInput};
pub use recent::RecentConnection;
#[allow(unused_imports)] #[allow(unused_imports)]
pub use db_viewer::{Change, ColumnInfo, FilterRule, Pagination, QueryResult, SortRule, TableInfo}; pub use db_viewer::{Change, ColumnInfo, FilterRule, Pagination, QueryResult, SortRule, TableInfo};
pub use folder::{Folder, FolderInput}; pub use folder::{Folder, FolderInput};
+7
View File
@@ -0,0 +1,7 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecentConnection {
pub connection_id: String,
pub opened_at: String,
}
+109 -4
View File
@@ -19,6 +19,11 @@ const CONNECTION_COLUMNS_V3: &[(&str, &str)] = &[
("environment", "TEXT"), ("environment", "TEXT"),
]; ];
/// New columns added in version 7.
const CONNECTION_COLUMNS_V7: &[(&str, &str)] = &[
("favorite", "INTEGER NOT NULL DEFAULT 0"),
];
pub fn run_migrations(conn: &Connection) -> Result<(), String> { pub fn run_migrations(conn: &Connection) -> Result<(), String> {
conn.execute_batch( conn.execute_batch(
"CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY); "CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY);
@@ -219,6 +224,42 @@ pub fn run_migrations(conn: &Connection) -> Result<(), String> {
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
} }
// v7: connection favorites + recent_connections
if current_ver < 7 {
let existing: Vec<String> = {
let mut stmt = conn
.prepare("PRAGMA table_info(connections)")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |row| row.get::<_, String>(1))
.map_err(|e| e.to_string())?;
rows.filter_map(|r| r.ok()).collect()
};
for (col_name, col_type) in CONNECTION_COLUMNS_V7 {
if !existing.contains(&col_name.to_string()) {
let sql = format!(
"ALTER TABLE connections ADD COLUMN {} {}",
col_name, col_type
);
conn.execute(&sql, []).map_err(|e| e.to_string())?;
}
}
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS recent_connections (
connection_id TEXT PRIMARY KEY REFERENCES connections(id) ON DELETE CASCADE,
opened_at TEXT NOT NULL
);"
).map_err(|e| e.to_string())?;
conn.execute(
"INSERT INTO schema_version (version) VALUES (7)",
[],
)
.map_err(|e| e.to_string())?;
}
Ok(()) Ok(())
} }
@@ -269,7 +310,7 @@ mod tests {
let count: i64 = conn let count: i64 = conn
.query_row("SELECT COUNT(*) FROM schema_version", [], |row| row.get(0)) .query_row("SELECT COUNT(*) FROM schema_version", [], |row| row.get(0))
.unwrap(); .unwrap();
assert_eq!(count, 5); assert_eq!(count, 6);
} }
#[test] #[test]
@@ -408,13 +449,77 @@ mod tests {
fn v6_bumps_schema_version_to_6() { fn v6_bumps_schema_version_to_6() {
let conn = Connection::open_in_memory().unwrap(); let conn = Connection::open_in_memory().unwrap();
run_migrations(&conn).unwrap(); run_migrations(&conn).unwrap();
let ver: i64 = conn let count: i64 = conn
.query_row( .query_row(
"SELECT MAX(version) FROM schema_version", "SELECT COUNT(*) FROM schema_version WHERE version = 6",
[], [],
|r| r.get(0), |r| r.get(0),
) )
.unwrap(); .unwrap();
assert_eq!(ver, 6, "Schema version should be 6 after v6 migration"); assert_eq!(count, 1, "Schema version 6 should be recorded after v6 migration");
}
#[test]
fn v7_adds_favorite_column_to_connections() {
let conn = Connection::open_in_memory().unwrap();
run_migrations(&conn).unwrap();
let columns: Vec<String> = {
let mut stmt = conn.prepare("PRAGMA table_info(connections)").unwrap();
let rows = stmt.query_map([], |row| row.get::<_, String>(1)).unwrap();
rows.filter_map(|r| r.ok()).collect()
};
assert!(
columns.contains(&"favorite".to_string()),
"Expected connections to have a 'favorite' column after v7"
);
let conn_id = "fav-test";
conn.execute(
"INSERT INTO connections (id, name, db_type, host, port, created_at, updated_at) VALUES (?1, 't', 'postgresql', 'h', 5432, datetime('now'), datetime('now'))",
rusqlite::params![conn_id],
).unwrap();
let fav: i64 = conn.query_row(
"SELECT favorite FROM connections WHERE id = ?1", rusqlite::params![conn_id], |r| r.get(0),
).unwrap();
assert_eq!(fav, 0, "favorite defaults to 0");
}
#[test]
fn v7_creates_recent_connections_table() {
let conn = Connection::open_in_memory().unwrap();
run_migrations(&conn).unwrap();
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM recent_connections", [], |r| r.get(0))
.unwrap();
assert_eq!(count, 0);
}
#[test]
fn v7_recent_connections_cascade_on_connection_delete() {
let conn = Connection::open_in_memory().unwrap();
run_migrations(&conn).unwrap();
let conn_id = "rc-cascade";
conn.execute(
"INSERT INTO connections (id, name, db_type, host, port, created_at, updated_at) VALUES (?1, 't', 'postgresql', 'h', 5432, datetime('now'), datetime('now'))",
rusqlite::params![conn_id],
).unwrap();
conn.execute(
"INSERT INTO recent_connections (connection_id, opened_at) VALUES (?1, datetime('now'))",
rusqlite::params![conn_id],
).unwrap();
conn.execute("DELETE FROM connections WHERE id = ?1", rusqlite::params![conn_id]).unwrap();
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM recent_connections WHERE connection_id = ?1", rusqlite::params![conn_id], |r| r.get(0))
.unwrap();
assert_eq!(count, 0);
}
#[test]
fn v7_bumps_schema_version_to_7() {
let conn = Connection::open_in_memory().unwrap();
run_migrations(&conn).unwrap();
let ver: i64 = conn
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
.unwrap();
assert_eq!(ver, 7);
} }
} }
+110 -3
View File
@@ -248,7 +248,7 @@ impl Store {
let conn = self.conn.lock().map_err(|e| e.to_string())?; let conn = self.conn.lock().map_err(|e| e.to_string())?;
let mut stmt = conn let mut stmt = conn
.prepare( .prepare(
"SELECT id, name, db_type, host, port, username, database, folder_id, keychain_ref, ssh_host, ssh_port, ssh_user, ssh_auth_method, ssh_private_key_path, ssl_mode, ssl_ca_path, ssl_cert_path, ssl_key_path, environment, created_at, updated_at FROM connections ORDER BY name", "SELECT id, name, db_type, host, port, username, database, folder_id, keychain_ref, ssh_host, ssh_port, ssh_user, ssh_auth_method, ssh_private_key_path, ssl_mode, ssl_ca_path, ssl_cert_path, ssl_key_path, environment, favorite, created_at, updated_at FROM connections ORDER BY name",
) )
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let rows = stmt let rows = stmt
@@ -273,9 +273,10 @@ impl Store {
ssl_cert_path: row.get(16)?, ssl_cert_path: row.get(16)?,
ssl_key_path: row.get(17)?, ssl_key_path: row.get(17)?,
environment: row.get(18)?, environment: row.get(18)?,
favorite: row.get(19)?,
tag_ids: vec![], tag_ids: vec![],
created_at: row.get(19)?, created_at: row.get(20)?,
updated_at: row.get(20)?, updated_at: row.get(21)?,
}) })
}) })
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@@ -320,6 +321,7 @@ impl Store {
database: input.database, database: input.database,
keychain_ref: None, keychain_ref: None,
environment: input.environment, environment: input.environment,
favorite: false,
ssh_host: input.ssh_host, ssh_host: input.ssh_host,
ssh_port: input.ssh_port, ssh_port: input.ssh_port,
ssh_user: input.ssh_user, ssh_user: input.ssh_user,
@@ -335,6 +337,64 @@ impl Store {
}) })
} }
pub fn set_connection_favorite(&self, id: &str, favorite: bool) -> Result<(), String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
let fav: i64 = if favorite { 1 } else { 0 };
conn.execute(
"UPDATE connections SET favorite = ?1, updated_at = ?2 WHERE id = ?3",
params![fav, Self::now(), id],
)
.map_err(|e| e.to_string())?;
Ok(())
}
pub fn get_recent_connections(
&self,
limit: i64,
) -> Result<Vec<crate::models::RecentConnection>, String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare(
"SELECT connection_id, opened_at FROM recent_connections ORDER BY opened_at DESC LIMIT ?1",
)
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map(params![limit], |row| {
Ok(crate::models::RecentConnection {
connection_id: row.get(0)?,
opened_at: row.get(1)?,
})
})
.map_err(|e| e.to_string())?;
Ok(rows.filter_map(|r| r.ok()).collect())
}
pub fn record_recent_connection(&self, connection_id: &str) -> Result<(), String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
let now = Self::now();
conn.execute(
"INSERT INTO recent_connections (connection_id, opened_at) VALUES (?1, ?2)
ON CONFLICT(connection_id) DO UPDATE SET opened_at = excluded.opened_at",
params![connection_id, now],
)
.map_err(|e| e.to_string())?;
conn.execute(
"DELETE FROM recent_connections WHERE connection_id NOT IN (
SELECT connection_id FROM recent_connections ORDER BY opened_at DESC LIMIT 20
)",
[],
)
.map_err(|e| e.to_string())?;
Ok(())
}
pub fn clear_recent_connections(&self) -> Result<(), String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
conn.execute("DELETE FROM recent_connections", [])
.map_err(|e| e.to_string())?;
Ok(())
}
pub fn delete_connection(&self, id: &str) -> Result<(), String> { pub fn delete_connection(&self, id: &str) -> Result<(), String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?; let conn = self.conn.lock().map_err(|e| e.to_string())?;
conn.execute("DELETE FROM connections WHERE id = ?1", params![id]) conn.execute("DELETE FROM connections WHERE id = ?1", params![id])
@@ -375,6 +435,7 @@ impl Store {
folder_id: input.folder_id, folder_id: input.folder_id,
keychain_ref: None, keychain_ref: None,
environment: input.environment, environment: input.environment,
favorite: false,
ssh_host: input.ssh_host, ssh_host: input.ssh_host,
ssh_port: input.ssh_port, ssh_port: input.ssh_port,
ssh_user: input.ssh_user, ssh_user: input.ssh_user,
@@ -1282,6 +1343,52 @@ mod tests {
assert!(result.is_err(), "Unknown id should be an error"); assert!(result.is_err(), "Unknown id should be an error");
} }
#[test]
fn set_connection_favorite_toggles_and_persists() {
let store = fresh_store();
let conn = create_test_connection(&store, "fav-conn");
store.set_connection_favorite(&conn.id, true).unwrap();
let conns = store.get_connections().unwrap();
assert_eq!(conns[0].favorite, true);
store.set_connection_favorite(&conn.id, false).unwrap();
assert_eq!(store.get_connections().unwrap()[0].favorite, false);
}
#[test]
fn record_recent_connection_upserts_and_caps_at_20() {
let store = fresh_store();
for i in 0..25 {
let conn = create_test_connection(&store, &format!("c{}", i));
store.record_recent_connection(&conn.id).unwrap();
}
let recent = store.get_recent_connections(100).unwrap();
assert_eq!(recent.len(), 20, "recent list capped at 20");
}
#[test]
fn record_recent_connection_dedupes_and_bumps_to_top() {
let store = fresh_store();
let a = create_test_connection(&store, "a");
let b = create_test_connection(&store, "b");
store.record_recent_connection(&a.id).unwrap();
store.record_recent_connection(&b.id).unwrap();
store.record_recent_connection(&a.id).unwrap(); // a re-opened -> should be most recent
let recent = store.get_recent_connections(10).unwrap();
assert_eq!(recent.len(), 2, "dedupe keeps one row per connection");
let pos_a = recent.iter().position(|r| r.connection_id == a.id).unwrap();
let pos_b = recent.iter().position(|r| r.connection_id == b.id).unwrap();
assert!(pos_a < pos_b, "re-opened a must be most recent");
}
#[test]
fn clear_recent_connections_empties_table() {
let store = fresh_store();
let conn = create_test_connection(&store, "x");
store.record_recent_connection(&conn.id).unwrap();
store.clear_recent_connections().unwrap();
assert_eq!(store.get_recent_connections(10).unwrap().len(), 0);
}
fn create_test_connection(store: &Store, name: &str) -> crate::models::Connection { fn create_test_connection(store: &Store, name: &str) -> crate::models::Connection {
store store
.create_connection(ConnectionInput { .create_connection(ConnectionInput {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Gridline", "productName": "Gridline",
"version": "0.2.0", "version": "0.5.0",
"identifier": "com.adrianbonpin.gridline", "identifier": "com.adrianbonpin.gridline",
"build": { "build": {
"beforeDevCommand": "bun run dev", "beforeDevCommand": "bun run dev",
@@ -13,7 +13,7 @@ const tags: Tag[] = [
const conn: Connection = { const conn: Connection = {
id: "c1", name: "Prod DB", db_type: "postgresql", host: "prod.example.com", id: "c1", name: "Prod DB", db_type: "postgresql", host: "prod.example.com",
port: 5432, username: null, folder_id: null, keychain_ref: null, port: 5432, username: null, folder_id: null, keychain_ref: null,
tag_ids: ["t1", "t2"], created_at: "", updated_at: "", tag_ids: ["t1", "t2"], favorite: false, created_at: "", updated_at: "",
}; };
function Wrapper({ children }: { children: React.ReactNode }) { function Wrapper({ children }: { children: React.ReactNode }) {
@@ -22,6 +22,7 @@ function Wrapper({ children }: { children: React.ReactNode }) {
describe("ConnectionCard", () => { describe("ConnectionCard", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks();
useUiStore.setState({ selectedItemIds: [] }); useUiStore.setState({ selectedItemIds: [] });
}); });
@@ -43,6 +44,24 @@ describe("ConnectionCard", () => {
render(<ConnectionCard connection={conn} tags={tags} />, { wrapper: Wrapper }); render(<ConnectionCard connection={conn} tags={tags} />, { wrapper: Wrapper });
expect(screen.getByLabelText("Drag to move connection")).toBeInTheDocument(); expect(screen.getByLabelText("Drag to move connection")).toBeInTheDocument();
}); });
it("mounts the connection actions kebab menu with action callbacks", () => {
const onEdit = vi.fn();
const onDuplicate = vi.fn();
const onDelete = vi.fn();
render(
<ConnectionCard
connection={conn}
tags={tags}
onEdit={onEdit}
onDuplicate={onDuplicate}
onDelete={onDelete}
/>,
{ wrapper: Wrapper },
);
expect(screen.getByLabelText("Connection actions")).toBeInTheDocument();
expect(screen.getByText("Prod DB")).toBeInTheDocument();
expect(screen.getByText("prod.example.com:5432")).toBeInTheDocument();
});
it("omits port for sqlite", () => { it("omits port for sqlite", () => {
const sqlite = { ...conn, db_type: "sqlite" as const, host: "/data/x.db", port: null }; const sqlite = { ...conn, db_type: "sqlite" as const, host: "/data/x.db", port: null };
render(<ConnectionCard connection={sqlite} tags={tags} />, { wrapper: Wrapper }); render(<ConnectionCard connection={sqlite} tags={tags} />, { wrapper: Wrapper });
@@ -74,4 +93,17 @@ describe("ConnectionCard", () => {
expect(fn).not.toHaveBeenCalled(); expect(fn).not.toHaveBeenCalled();
expect(useUiStore.getState().selectedItemIds).toContain(conn.id); expect(useUiStore.getState().selectedItemIds).toContain(conn.id);
}); });
it("no longer renders a favorite star (replaced by kebab menu)", () => {
render(<ConnectionCard connection={conn} tags={tags} />, { wrapper: Wrapper });
expect(screen.queryByLabelText(/favorite|unfavorite/i)).not.toBeInTheDocument();
});
it("no longer renders a status indicator and still shows name/host/tags", () => {
render(<ConnectionCard connection={{ ...conn, favorite: true }} tags={tags} />, { wrapper: Wrapper });
expect(screen.queryByLabelText(/check connection/i)).not.toBeInTheDocument();
expect(screen.getByText("Prod DB")).toBeInTheDocument();
expect(screen.getByText("prod.example.com:5432")).toBeInTheDocument();
expect(screen.getByText("production")).toBeInTheDocument();
});
}); });
+41 -2
View File
@@ -1,8 +1,9 @@
import { memo } from "react"; import { memo } from "react";
import type { Connection, Tag } from "../../lib/types"; import type { Connection, ConnectionInput, Tag } from "../../lib/types";
import { DbIcon, DB_LABELS } from "../../lib/dbIcons"; import { DbIcon, DB_LABELS } from "../../lib/dbIcons";
import { ENV_LABELS, ENV_COLORS } from "../../lib/environment"; import { ENV_LABELS, ENV_COLORS } from "../../lib/environment";
import { TagBadge } from "../tags/TagBadge"; import { TagBadge } from "../tags/TagBadge";
import { ConnectionCardMenu } from "./ConnectionCardMenu";
import { Check, GripVertical } from "lucide-react"; import { Check, GripVertical } from "lucide-react";
import { useUiStore } from "../../stores/uiStore"; import { useUiStore } from "../../stores/uiStore";
import { useDraggable } from "@dnd-kit/core"; import { useDraggable } from "@dnd-kit/core";
@@ -13,6 +14,35 @@ interface ConnectionCardProps {
tags: Tag[]; tags: Tag[];
onTagToggle?: (id: string) => void; onTagToggle?: (id: string) => void;
onOpenDbViewer?: (connectionId: string) => void; onOpenDbViewer?: (connectionId: string) => void;
onEdit?: (connection: Connection) => void;
onDuplicate?: (connection: Connection) => void;
onDelete?: (connection: Connection) => void;
}
export function buildConfigFromConnection(conn: Connection, password: string | null): ConnectionInput {
return {
name: conn.name,
db_type: conn.db_type,
host: conn.host,
port: conn.port,
username: conn.username,
folder_id: conn.folder_id,
tag_ids: conn.tag_ids,
password,
database: conn.database ?? null,
environment: conn.environment ?? null,
ssh_host: conn.ssh_host ?? null,
ssh_port: conn.ssh_port ?? null,
ssh_user: conn.ssh_user ?? null,
ssh_auth_method: (conn.ssh_auth_method as ConnectionInput["ssh_auth_method"]) ?? null,
ssh_private_key_path: conn.ssh_private_key_path ?? null,
ssh_password: null,
ssh_passphrase: null,
ssl_mode: (conn.ssl_mode as ConnectionInput["ssl_mode"]) ?? null,
ssl_ca_path: conn.ssl_ca_path ?? null,
ssl_cert_path: conn.ssl_cert_path ?? null,
ssl_key_path: conn.ssl_key_path ?? null,
};
} }
function ConnectionCardBase({ function ConnectionCardBase({
@@ -20,6 +50,9 @@ function ConnectionCardBase({
tags, tags,
onTagToggle, onTagToggle,
onOpenDbViewer, onOpenDbViewer,
onEdit,
onDuplicate,
onDelete,
}: ConnectionCardProps) { }: ConnectionCardProps) {
const selectedItemIds = useUiStore((s) => s.selectedItemIds); const selectedItemIds = useUiStore((s) => s.selectedItemIds);
const toggleItemSelection = useUiStore((s) => s.toggleItemSelection); const toggleItemSelection = useUiStore((s) => s.toggleItemSelection);
@@ -68,7 +101,7 @@ function ConnectionCardBase({
<div <div
{...listeners} {...listeners}
{...attributes} {...attributes}
className="absolute top-1/2 -translate-y-1/2 right-2 opacity-0 group-hover:opacity-100 transition-opacity cursor-grab z-10" className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity cursor-grab z-10"
aria-label="Drag to move connection" aria-label="Drag to move connection"
> >
<GripVertical size={14} className="text-text-muted" /> <GripVertical size={14} className="text-text-muted" />
@@ -107,6 +140,12 @@ function ConnectionCardBase({
))} ))}
</div> </div>
</div> </div>
<ConnectionCardMenu
connection={connection}
onEdit={() => onEdit?.(connection)}
onDuplicate={() => onDuplicate?.(connection)}
onDelete={() => onDelete?.(connection)}
/>
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -0,0 +1,203 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ConnectionCardMenu } from "./ConnectionCardMenu";
import { useConnectionStore } from "../../stores/connectionStore";
import * as commands from "../../lib/commands";
import type { Connection } from "../../lib/types";
vi.mock("../../lib/commands", () => ({
getConnectionPassword: vi.fn(),
testConnection: vi.fn(),
}));
const conn: Connection = {
id: "c1",
name: "Prod DB",
db_type: "postgresql",
host: "prod.example.com",
port: 5432,
username: null,
folder_id: null,
keychain_ref: null,
tag_ids: [],
favorite: false,
created_at: "",
updated_at: "",
};
function renderMenu() {
const onEdit = vi.fn();
const onDuplicate = vi.fn();
const onDelete = vi.fn();
const utils = render(
<ConnectionCardMenu
connection={conn}
onEdit={onEdit}
onDuplicate={onDuplicate}
onDelete={onDelete}
/>,
);
return { ...utils, onEdit, onDuplicate, onDelete };
}
async function openMenu() {
await userEvent.click(screen.getByLabelText("Connection actions"));
}
describe("ConnectionCardMenu", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("renders a kebab trigger and opens the menu on click", async () => {
renderMenu();
expect(screen.getByLabelText("Connection actions")).toBeInTheDocument();
expect(screen.queryByText("Manage")).not.toBeInTheDocument();
await openMenu();
expect(screen.getByText("Add to favorites")).toBeInTheDocument();
expect(screen.getByText("Test connection")).toBeInTheDocument();
expect(screen.getByText("Manage")).toBeInTheDocument();
});
it("favorite label reflects connection.favorite", async () => {
const { unmount } = renderMenu();
await openMenu();
expect(screen.getByText("Add to favorites")).toBeInTheDocument();
unmount();
render(
<ConnectionCardMenu
connection={{ ...conn, favorite: true }}
onEdit={vi.fn()}
onDuplicate={vi.fn()}
onDelete={vi.fn()}
/>,
);
await openMenu();
expect(screen.getByText("Remove from favorites")).toBeInTheDocument();
});
it("clicking favorite calls toggleFavorite and closes the menu", async () => {
const spy = vi
.spyOn(useConnectionStore.getState(), "toggleFavorite")
.mockResolvedValue(undefined);
renderMenu();
await openMenu();
await userEvent.click(screen.getByText("Add to favorites"));
expect(spy).toHaveBeenCalledWith("c1");
expect(screen.queryByText("Manage")).not.toBeInTheDocument();
});
it("test connection keeps the menu open and shows online status", async () => {
vi.mocked(commands.getConnectionPassword).mockResolvedValue("pw");
vi.mocked(commands.testConnection).mockResolvedValue({
ok: true,
server_version: "15.2",
latency_ms: 12,
} as any);
renderMenu();
await openMenu();
await userEvent.click(screen.getByText("Test connection"));
// Menu stays open while the check runs
expect(screen.getByText("Manage")).toBeInTheDocument();
await waitFor(() =>
expect(screen.getByText(/online/i)).toBeInTheDocument(),
);
expect(screen.getByText(/15\.2/)).toBeInTheDocument();
expect(screen.getByText(/12ms/)).toBeInTheDocument();
});
it("test connection shows just Online when no version/latency reported", async () => {
vi.mocked(commands.getConnectionPassword).mockResolvedValue("pw");
vi.mocked(commands.testConnection).mockResolvedValue({ ok: true } as any);
renderMenu();
await openMenu();
await userEvent.click(screen.getByText("Test connection"));
await waitFor(() =>
expect(
screen.getByText("Online", { exact: true }),
).toBeInTheDocument(),
);
expect(screen.queryByText(/unknown/)).not.toBeInTheDocument();
expect(screen.queryByText(/0ms/)).not.toBeInTheDocument();
});
it("test connection shows the offline error text", async () => {
vi.mocked(commands.getConnectionPassword).mockResolvedValue("pw");
vi.mocked(commands.testConnection).mockResolvedValue({
ok: false,
error: "connection refused",
} as any);
renderMenu();
await openMenu();
await userEvent.click(screen.getByText("Test connection"));
await waitFor(() =>
expect(screen.getByText(/connection refused/i)).toBeInTheDocument(),
);
});
it("Manage expands to reveal Edit/Duplicate/Delete and Edit calls onEdit + closes", async () => {
const { onEdit } = renderMenu();
await openMenu();
await userEvent.click(screen.getByText("Manage"));
expect(screen.getByText("Edit…")).toBeInTheDocument();
expect(screen.getByText("Duplicate")).toBeInTheDocument();
expect(screen.getByText("Delete…")).toBeInTheDocument();
await userEvent.click(screen.getByText("Edit…"));
expect(onEdit).toHaveBeenCalledTimes(1);
expect(screen.queryByText("Edit…")).not.toBeInTheDocument();
});
it("Duplicate calls onDuplicate + closes the menu", async () => {
const { onDuplicate } = renderMenu();
await openMenu();
await userEvent.click(screen.getByText("Manage"));
await userEvent.click(screen.getByText("Duplicate"));
expect(onDuplicate).toHaveBeenCalledTimes(1);
expect(screen.queryByText("Manage")).not.toBeInTheDocument();
});
it("Delete is rendered in red, calls onDelete + closes the menu", async () => {
const { onDelete } = renderMenu();
await openMenu();
await userEvent.click(screen.getByText("Manage"));
const deleteItem = screen.getByText("Delete…");
expect(deleteItem.className).toContain("text-red");
await userEvent.click(deleteItem);
expect(onDelete).toHaveBeenCalledTimes(1);
expect(screen.queryByText("Manage")).not.toBeInTheDocument();
});
it("closes on outside mousedown", async () => {
renderMenu();
await openMenu();
expect(screen.getByText("Manage")).toBeInTheDocument();
fireEvent.mouseDown(document.body);
expect(screen.queryByText("Manage")).not.toBeInTheDocument();
});
it("closes on Escape", async () => {
renderMenu();
await openMenu();
expect(screen.getByText("Manage")).toBeInTheDocument();
await userEvent.keyboard("{Escape}");
expect(screen.queryByText("Manage")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,227 @@
import { useEffect, useRef, useState } from "react";
import {
Activity,
ChevronRight,
CircleCheck,
CircleX,
Copy,
Loader2,
MoreVertical,
Pencil,
Star,
Trash2,
} from "lucide-react";
import type { Connection } from "../../lib/types";
import { useConnectionStore } from "../../stores/connectionStore";
import { buildConfigFromConnection } from "./ConnectionCard";
import { useConnectionStatus } from "./useConnectionStatus";
interface ConnectionCardMenuProps {
connection: Connection;
onEdit: () => void;
onDuplicate: () => void;
onDelete: () => void;
}
const menuItemClass =
"flex w-full items-center gap-2 px-3 py-2 text-left text-xs text-text hover:bg-surface transition-colors cursor-pointer";
/**
* Kebab () actions menu for a connection card. Hosts the favorite toggle,
* on-demand connection test (inline status), and a Manage submenu
* (Edit / Duplicate / Delete). Closes on outside mousedown, Escape, and
* after selecting an action.
*/
export function ConnectionCardMenu({
connection,
onEdit,
onDuplicate,
onDelete,
}: ConnectionCardMenuProps) {
const [open, setOpen] = useState(false);
const [manageOpen, setManageOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const { state, info, check } = useConnectionStatus(
connection.id,
(pw) => buildConfigFromConnection(connection, pw),
);
useEffect(() => {
if (!open) return;
const handleMouseDown = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpen(false);
setManageOpen(false);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setOpen(false);
setManageOpen(false);
}
};
// Capture phase so we fire before other stopPropagation handlers
document.addEventListener("mousedown", handleMouseDown, true);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handleMouseDown, true);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
const toggleMenu = () => {
setOpen((prev) => {
const next = !prev;
if (!next) setManageOpen(false);
return next;
});
};
const close = () => {
setOpen(false);
setManageOpen(false);
};
const statusLabel =
state === "checking"
? "Testing…"
: state === "online"
? `Online${info ? ` · ${info}` : ""}`
: state === "offline"
? info
: "Test connection";
const statusIcon =
state === "checking" ? (
<Loader2 size={14} className="animate-spin text-text-muted" />
) : state === "online" ? (
<CircleCheck size={14} className="shrink-0 text-green-500" />
) : state === "offline" ? (
<CircleX size={14} className="shrink-0 text-red-500" />
) : (
<Activity size={14} className="text-text-muted" />
);
return (
<div className="absolute top-1/2 -translate-y-1/2 right-0.5 z-10" ref={menuRef}>
<button
type="button"
aria-label="Connection actions"
aria-haspopup="menu"
aria-expanded={open}
onClick={(e) => {
e.stopPropagation();
toggleMenu();
}}
className={`rounded-md p-1 text-text-muted hover:text-text hover:bg-surface transition-colors cursor-pointer ${
open ? "opacity-100" : "opacity-0 group-hover:opacity-100"
}`}
>
<MoreVertical size={16} />
</button>
{open && (
<div
onClick={(e) => e.stopPropagation()}
className="absolute top-full right-0 mt-1 w-64 rounded-md border border-border bg-canvas shadow-lg z-20 py-1 text-xs"
>
<button
type="button"
onClick={() => {
void useConnectionStore
.getState()
.toggleFavorite(connection.id)
.catch(() => {});
close();
}}
className={menuItemClass}
>
<Star
size={14}
className={
connection.favorite
? "text-amber-400 fill-amber-400"
: "text-text-muted"
}
/>
<span className="truncate">
{connection.favorite
? "Remove from favorites"
: "Add to favorites"}
</span>
</button>
<button
type="button"
onClick={() => {
void check();
}}
className={menuItemClass}
>
{statusIcon}
<span
className={
state === "offline"
? "whitespace-normal break-words text-red-500"
: "truncate"
}
>
{statusLabel}
</span>
</button>
<button
type="button"
onClick={() => setManageOpen((m) => !m)}
className={`${menuItemClass} justify-between`}
>
<span>Manage</span>
<ChevronRight
size={14}
className={`text-text-muted transition-transform ${
manageOpen ? "rotate-90" : ""
}`}
/>
</button>
{manageOpen && (
<div className="mt-1 border-t border-border pt-1">
<button
type="button"
onClick={() => {
onEdit();
close();
}}
className={`${menuItemClass} pl-6`}
>
<Pencil size={14} className="text-text-muted" />
Edit
</button>
<button
type="button"
onClick={() => {
onDuplicate();
close();
}}
className={`${menuItemClass} pl-6`}
>
<Copy size={14} className="text-text-muted" />
Duplicate
</button>
<button
type="button"
onClick={() => {
onDelete();
close();
}}
className={`${menuItemClass} pl-6 !text-red-500 hover:!text-red-400`}
>
<Trash2 size={14} className="text-red-500" />
Delete
</button>
</div>
)}
</div>
)}
</div>
);
}
@@ -8,7 +8,7 @@ import type { Connection, Folder, Tag } from "../../lib/types";
const makeConn = (id: string, folder_id: string | null = null): Connection => ({ const makeConn = (id: string, folder_id: string | null = null): Connection => ({
id, name: `Conn ${id}`, db_type: "postgresql", host: "h", port: 5432, id, name: `Conn ${id}`, db_type: "postgresql", host: "h", port: 5432,
username: null, folder_id, keychain_ref: null, tag_ids: [], username: null, folder_id, keychain_ref: null, tag_ids: [],
created_at: "", updated_at: "", environment: null, created_at: "", updated_at: "", environment: null, favorite: false,
}); });
const folders: Folder[] = [ const folders: Folder[] = [
@@ -109,6 +109,9 @@ interface ConnectionGridProps {
onEditFolder?: (folder: Folder) => void; onEditFolder?: (folder: Folder) => void;
onDeleteFolder?: (folder: Folder) => void; onDeleteFolder?: (folder: Folder) => void;
onOpenDbViewer?: (connectionId: string) => void; onOpenDbViewer?: (connectionId: string) => void;
onEditConnection?: (conn: Connection) => void;
onDuplicateConnection?: (conn: Connection) => void;
onDeleteConnection?: (conn: Connection) => void;
} }
export function ConnectionGrid({ export function ConnectionGrid({
@@ -122,6 +125,9 @@ export function ConnectionGrid({
onEditFolder, onEditFolder,
onDeleteFolder, onDeleteFolder,
onOpenDbViewer, onOpenDbViewer,
onEditConnection,
onDuplicateConnection,
onDeleteConnection,
}: ConnectionGridProps) { }: ConnectionGridProps) {
const selectedItemIds = useUiStore((s) => s.selectedItemIds); const selectedItemIds = useUiStore((s) => s.selectedItemIds);
const toggleItemSelection = useUiStore((s) => s.toggleItemSelection); const toggleItemSelection = useUiStore((s) => s.toggleItemSelection);
@@ -272,6 +278,9 @@ export function ConnectionGrid({
tags={tags} tags={tags}
onTagToggle={onTagToggle} onTagToggle={onTagToggle}
onOpenDbViewer={onOpenDbViewer} onOpenDbViewer={onOpenDbViewer}
onEdit={onEditConnection}
onDuplicate={onDuplicateConnection}
onDelete={onDeleteConnection}
/> />
))} ))}
</div> </div>
@@ -0,0 +1,80 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { MoveToFolderDialog } from "./MoveToFolderDialog";
import type { Folder } from "../../lib/types";
const makeFolder = (id: string, name: string): Folder => ({
id,
name,
parent_id: null,
tag_ids: [],
created_at: "",
updated_at: "",
});
describe("MoveToFolderDialog", () => {
it("lists folders + Root and calls onConfirm with the chosen id", () => {
const onConfirm = vi.fn();
const onClose = vi.fn();
render(
<MoveToFolderDialog
open
folders={[makeFolder("f1", "Prod")]}
selectedCount={3}
onConfirm={onConfirm}
onClose={onClose}
/>,
);
expect(screen.getByText(/3 items/)).toBeInTheDocument();
fireEvent.click(screen.getByText("Prod"));
fireEvent.click(screen.getByRole("button", { name: /move/i }));
expect(onConfirm).toHaveBeenCalledWith("f1");
});
it("Root option passes null", () => {
const onConfirm = vi.fn();
render(
<MoveToFolderDialog
open
folders={[]}
selectedCount={1}
onConfirm={onConfirm}
onClose={vi.fn()}
/>,
);
fireEvent.click(screen.getByText(/root/i));
fireEvent.click(screen.getByRole("button", { name: /move/i }));
expect(onConfirm).toHaveBeenCalledWith(null);
});
it("Cancel button calls onClose", () => {
const onClose = vi.fn();
render(
<MoveToFolderDialog
open
folders={[]}
selectedCount={2}
onConfirm={vi.fn()}
onClose={onClose}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
expect(onClose).toHaveBeenCalled();
});
it("Move button is disabled until an option is chosen", () => {
render(
<MoveToFolderDialog
open
folders={[makeFolder("f1", "Prod")]}
selectedCount={1}
onConfirm={vi.fn()}
onClose={vi.fn()}
/>,
);
const move = screen.getByRole("button", { name: /move/i });
expect(move).toBeDisabled();
fireEvent.click(screen.getByText("Prod"));
expect(move).not.toBeDisabled();
});
});
@@ -0,0 +1,93 @@
import { useEffect, useState } from "react";
import { Folder as FolderIcon, Check } from "lucide-react";
import { AnimatedModal } from "../ui/AnimatedModal";
import { Button } from "../ui/Button";
import type { Folder } from "../../lib/types";
interface MoveToFolderDialogProps {
open: boolean;
folders: Folder[];
selectedCount: number;
onConfirm: (targetFolderId: string | null) => void;
onClose: () => void;
}
export function MoveToFolderDialog({
open,
folders,
selectedCount,
onConfirm,
onClose,
}: MoveToFolderDialogProps) {
const [target, setTarget] = useState<string | null>(null);
const [selected, setSelected] = useState(false);
// Reset on close so a fresh open starts unselected (null is a valid target = Root).
useEffect(() => {
if (!open) {
setTarget(null);
setSelected(false);
}
}, [open]);
const handleConfirm = () => {
onConfirm(target);
};
const isSelected = (id: string | null) => target === id;
return (
<AnimatedModal open={open} onClose={onClose}>
<div className="w-[360px]">
<h3 className="font-heading text-lg text-text mb-4">
Move {selectedCount} item{selectedCount !== 1 ? "s" : ""} to folder
</h3>
<div className="max-h-[300px] overflow-y-auto space-y-1 pr-1">
<button
type="button"
onClick={() => {
setTarget(null);
setSelected(true);
}}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left text-sm transition-colors cursor-pointer ${
isSelected(null)
? "bg-accent/10 text-text"
: "text-text-muted hover:text-text hover:bg-surface-raised"
}`}
>
<FolderIcon size={14} />
<span className="flex-1">Root (no folder)</span>
{isSelected(null) && <Check size={14} className="text-accent" />}
</button>
{folders.map((folder) => (
<button
key={folder.id}
type="button"
onClick={() => {
setTarget(folder.id);
setSelected(true);
}}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left text-sm transition-colors cursor-pointer ${
isSelected(folder.id)
? "bg-accent/10 text-text"
: "text-text-muted hover:text-text hover:bg-surface-raised"
}`}
>
<FolderIcon size={14} />
<span className="flex-1 truncate">{folder.name}</span>
{isSelected(folder.id) && <Check size={14} className="text-accent" />}
</button>
))}
</div>
<div className="flex justify-end gap-2 mt-5">
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleConfirm} disabled={!selected}>
Move
</Button>
</div>
</div>
</AnimatedModal>
);
}
@@ -0,0 +1,37 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { RecentConnectionsStrip } from "./RecentConnectionsStrip";
import type { Connection } from "../../lib/types";
const makeConn = (id: string): Connection => ({
id,
name: id.toUpperCase(),
db_type: "postgresql",
host: "h",
port: 5432,
username: null,
folder_id: null,
keychain_ref: null,
tag_ids: [],
created_at: "",
updated_at: "",
favorite: false,
});
describe("RecentConnectionsStrip", () => {
it("renders up to 8 recent connections and calls onOpen on click", () => {
const onOpen = vi.fn();
const recents = Array.from({ length: 10 }, (_, i) => makeConn(`c${i}`));
render(<RecentConnectionsStrip recents={recents} onOpen={onOpen} />);
expect(screen.getAllByRole("button")).toHaveLength(8);
fireEvent.click(screen.getByText("C0"));
expect(onOpen).toHaveBeenCalledWith("c0");
});
it("renders nothing when the list is empty", () => {
const { container } = render(
<RecentConnectionsStrip recents={[]} onOpen={vi.fn()} />,
);
expect(container.firstChild).toBeNull();
});
});
@@ -0,0 +1,37 @@
import { DbIcon } from "../../lib/dbIcons";
import type { Connection } from "../../lib/types";
interface RecentConnectionsStripProps {
recents: Connection[];
onOpen: (id: string) => void;
}
export function RecentConnectionsStrip({
recents,
onOpen,
}: RecentConnectionsStripProps) {
if (recents.length === 0) return null;
const visible = recents.slice(0, 8);
return (
<div className="mb-4">
<h3 className="text-xs font-medium text-text-muted uppercase tracking-wide mb-2">
Recent
</h3>
<div className="flex items-center gap-2 overflow-x-auto pb-1">
{visible.map((connection) => (
<button
key={connection.id}
type="button"
onClick={() => onOpen(connection.id)}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-surface border border-border hover:border-border-hover text-sm text-text transition-colors cursor-pointer whitespace-nowrap"
>
<DbIcon type={connection.db_type} size={14} />
<span className="truncate max-w-[180px]">{connection.name}</span>
</button>
))}
</div>
</div>
);
}
@@ -0,0 +1,106 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useConnectionStatus } from "./useConnectionStatus";
import * as commands from "../../lib/commands";
describe("useConnectionStatus", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("starts idle and becomes online with server_version/latency info after a successful check", async () => {
vi.spyOn(commands, "getConnectionPassword").mockResolvedValue("pw");
vi.spyOn(commands, "testConnection").mockResolvedValue({
ok: true,
server_version: "15.2",
latency_ms: 12,
} as any);
const { result } = renderHook(() =>
useConnectionStatus("c1", () => ({
name: "P",
db_type: "postgresql",
host: "h",
port: 5432,
username: "u",
password: "pw",
} as any)),
);
expect(result.current.state).toBe("idle");
await act(async () => {
await result.current.check();
});
expect(result.current.state).toBe("online");
expect(result.current.info).toContain("15.2");
expect(result.current.info).toContain("12ms");
});
it("becomes offline with error info when testConnection fails", async () => {
vi.spyOn(commands, "getConnectionPassword").mockResolvedValue("pw");
vi.spyOn(commands, "testConnection").mockResolvedValue({
ok: false,
error: "timeout",
} as any);
const { result } = renderHook(() =>
useConnectionStatus("c1", () => ({
name: "P",
db_type: "postgresql",
host: "h",
port: 5432,
username: "u",
} as any)),
);
await act(async () => {
await result.current.check();
});
expect(result.current.state).toBe("offline");
expect(result.current.info).toContain("timeout");
});
it("online with no version/latency reported keeps info empty (no unknown/0ms fallback)", async () => {
vi.spyOn(commands, "getConnectionPassword").mockResolvedValue("pw");
vi.spyOn(commands, "testConnection").mockResolvedValue({ ok: true } as any);
const { result } = renderHook(() =>
useConnectionStatus("c1", () => ({
name: "P",
db_type: "postgresql",
host: "h",
port: 5432,
username: "u",
} as any)),
);
await act(async () => {
await result.current.check();
});
expect(result.current.state).toBe("online");
expect(result.current.info).toBe("");
});
it("debounces: rapid check() calls run one check; a re-check is allowed after 2s", async () => {
vi.useFakeTimers({ now: 100_000 });
try {
const pw = vi.spyOn(commands, "getConnectionPassword").mockResolvedValue("pw");
vi.spyOn(commands, "testConnection").mockResolvedValue({ ok: true } as any);
const { result } = renderHook(() =>
useConnectionStatus("c1", () => ({
name: "P",
db_type: "postgresql",
host: "h",
port: 5432,
} as any)),
);
await act(async () => {
const p1 = result.current.check();
const p2 = result.current.check();
await Promise.all([p1, p2]);
});
expect(pw).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(2000);
await act(async () => {
await result.current.check();
});
expect(pw).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
});
});
@@ -0,0 +1,75 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { ConnectionInput, ConnectionTestResult } from "../../lib/types";
import * as cmd from "../../lib/commands";
export type ConnectionStatusState = "idle" | "checking" | "online" | "offline";
const DEBOUNCE_MS = 2000;
const RESET_MS = 5000;
/**
* Extracted from the former status-dot UI: runs a connection test on
* demand, debounced to once per 2s, and auto-resets to "idle" 5s after the
* last check. `check()` is stable (useCallback) and safe to hand to menu
* handlers; it no-ops while a check is already in flight or within the
* debounce window.
*/
export function useConnectionStatus(
connectionId: string,
buildConfig: (password: string | null) => ConnectionInput,
): { state: ConnectionStatusState; info: string; check: () => Promise<void> } {
const [state, setState] = useState<ConnectionStatusState>("idle");
const [info, setInfo] = useState<string>("");
const timeoutRef = useRef<number | null>(null);
const lastCheckRef = useRef<number>(0);
// Mirror `state` in a ref so `check` stays stable while still being able to
// skip a second run while one is already in flight.
const stateRef = useRef<ConnectionStatusState>("idle");
const setStateBoth = useCallback((next: ConnectionStatusState) => {
stateRef.current = next;
setState(next);
}, []);
useEffect(() => {
return () => {
if (timeoutRef.current) window.clearTimeout(timeoutRef.current);
};
}, []);
const check = useCallback(async () => {
const now = Date.now();
if (stateRef.current === "checking") return;
if (now - lastCheckRef.current < DEBOUNCE_MS) return;
lastCheckRef.current = now;
setStateBoth("checking");
setInfo("");
try {
const password = await cmd.getConnectionPassword(connectionId);
const input = buildConfig(password);
const result: ConnectionTestResult = await cmd.testConnection(input);
if (result.ok) {
const parts: string[] = [];
if (result.server_version) parts.push(result.server_version);
if (result.latency_ms != null) parts.push(`${result.latency_ms}ms`);
setInfo(parts.join(" · "));
setStateBoth("online");
} else {
setInfo(result.error ?? "offline");
setStateBoth("offline");
}
} catch (e) {
setInfo(e instanceof Error ? e.message : "offline");
setStateBoth("offline");
}
if (timeoutRef.current) window.clearTimeout(timeoutRef.current);
timeoutRef.current = window.setTimeout(() => {
setStateBoth("idle");
setInfo("");
}, RESET_MS);
}, [connectionId, buildConfig, setStateBoth]);
return { state, info, check };
}
@@ -34,6 +34,22 @@ describe("ChangesQueuePanel", () => {
expect(screen.getByText(/public.users/i)).toBeInTheDocument(); expect(screen.getByText(/public.users/i)).toBeInTheDocument();
}); });
it("shows the old → new value diff on update cards", () => {
useDbViewerStore.getState().addChange({
type: "update",
schema: "public",
table: "users",
primaryKey: { id: 1 },
oldData: { name: "Bob" },
newData: { name: "Alice" },
description: "Update row in users",
} as any);
render(<ChangesQueuePanel />);
// the diff renders old (struck) → new (accent) as separate spans
expect(screen.getByText(/name: Bob/)).toBeInTheDocument();
expect(screen.getByText("Alice")).toBeInTheDocument();
});
it("revert removes the change from the queue", async () => { it("revert removes the change from the queue", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
useDbViewerStore.getState().addChange({ useDbViewerStore.getState().addChange({
@@ -156,6 +172,21 @@ describe("ChangesQueuePanel", () => {
expect(useDbViewerStore.getState().changesQueue).toHaveLength(0); expect(useDbViewerStore.getState().changesQueue).toHaveLength(0);
}); });
it("calls onCommitted after a successful commit cycle", async () => {
const onCommitted = vi.fn();
vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
useDbViewerStore.getState().addChange({
type: "insert",
schema: "public",
table: "t",
newData: { a: 1 },
description: "Insert row into t",
} as any);
render(<ChangesQueuePanel onCommitted={onCommitted} />);
fireEvent.click(screen.getByRole("button", { name: /commit all/i }));
await waitFor(() => expect(onCommitted).toHaveBeenCalledTimes(1));
});
it("shows a green check on committed changes after Commit All", async () => { it("shows a green check on committed changes after Commit All", async () => {
vi.spyOn(commands, "executeChange").mockResolvedValue(undefined); vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
useDbViewerStore.getState().addChange({ type: "insert", schema: "public", table: "t", newData: { a: 1 }, description: "Insert row into t" } as any); useDbViewerStore.getState().addChange({ type: "insert", schema: "public", table: "t", newData: { a: 1 }, description: "Insert row into t" } as any);
+29 -1
View File
@@ -30,6 +30,22 @@ function formatChangeLabel(change: QueueItem): string {
} }
} }
/** Render the old → new value change for update queue items. */
function formatValueDiff(change: QueueItem): string | null {
if (change.type !== "update" || !change.newData) return null;
const colName = Object.keys(change.newData)[0];
if (!colName) return null;
const oldVal =
change.oldData && change.oldData[colName] !== undefined
? String(change.oldData[colName])
: "NULL";
const newVal =
change.newData[colName] === null || change.newData[colName] === undefined
? "NULL"
: String(change.newData[colName]);
return `${colName}: ${oldVal}${newVal}`;
}
function capitalizeType(type: string) { function capitalizeType(type: string) {
return type.charAt(0).toUpperCase() + type.slice(1); return type.charAt(0).toUpperCase() + type.slice(1);
} }
@@ -39,7 +55,7 @@ function tableRef(change: QueueItem): string {
return change.table ?? "-"; return change.table ?? "-";
} }
export function ChangesQueuePanel() { export function ChangesQueuePanel({ onCommitted }: { onCommitted?: () => void } = {}) {
const changesQueue = useDbViewerStore((state) => state.changesQueue); const changesQueue = useDbViewerStore((state) => state.changesQueue);
const removeChange = useDbViewerStore((state) => state.removeChange); const removeChange = useDbViewerStore((state) => state.removeChange);
const clearChanges = useDbViewerStore((state) => state.clearChanges); const clearChanges = useDbViewerStore((state) => state.clearChanges);
@@ -84,6 +100,7 @@ export function ChangesQueuePanel() {
} }
if (committedCount > 0) { if (committedCount > 0) {
onCommitted?.();
notify(`${committedCount} change(s) committed`, "success"); notify(`${committedCount} change(s) committed`, "success");
} }
@@ -183,6 +200,17 @@ export function ChangesQueuePanel() {
<div className="mt-1 text-xs text-text-muted truncate"> <div className="mt-1 text-xs text-text-muted truncate">
{formatChangeLabel(change)} {formatChangeLabel(change)}
</div> </div>
{formatValueDiff(change) && (
<div className="mt-0.5 font-mono text-xs text-text">
<span className="text-text-muted line-through">
{formatValueDiff(change)!.split(" → ")[0]}
</span>
<span className="mx-1 text-text-muted"></span>
<span className="text-accent">
{formatValueDiff(change)!.split(" → ")[1]}
</span>
</div>
)}
</div> </div>
)) ))
) : ( ) : (
@@ -1,7 +1,13 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { DbViewerScreen } from "./DbViewerScreen"; import {
DbViewerScreen,
deriveStagedValues,
derivePendingCellKeys,
pickDisplayColumn,
} from "./DbViewerScreen";
import { useDbViewerStore } from "../../stores/dbViewerStore"; import { useDbViewerStore } from "../../stores/dbViewerStore";
import { useUiStore } from "../../stores/uiStore";
import * as commands from "../../lib/commands"; import * as commands from "../../lib/commands";
vi.mock("../../hooks/useDbConnection", () => ({ vi.mock("../../hooks/useDbConnection", () => ({
@@ -12,9 +18,17 @@ vi.mock("../../hooks/useDbConnection", () => ({
})); }));
vi.mock("@tanstack/react-virtual", () => ({ vi.mock("@tanstack/react-virtual", () => ({
useVirtualizer: () => ({ useVirtualizer: ({ count }: any) => ({
getVirtualItems: () => [], getVirtualItems: () =>
getTotalSize: () => 0, count > 0
? Array.from({ length: count }, (_, i) => ({
key: i,
index: i,
start: i * 36,
size: 36,
}))
: [],
getTotalSize: () => count * 36,
measureElement: () => {}, measureElement: () => {},
}), }),
})); }));
@@ -64,6 +78,8 @@ const mockQueryResult = {
is_fk: false, is_fk: false,
fk_ref: null, fk_ref: null,
default_value: null, default_value: null,
editable: true,
is_generated: false,
}, },
], ],
rows: [[1]], rows: [[1]],
@@ -99,6 +115,126 @@ describe("DbViewerScreen", () => {
expect(screen.getByLabelText(/home/i)).toBeInTheDocument(); expect(screen.getByLabelText(/home/i)).toBeInTheDocument();
}); });
it("full flow: editing a cell shows the staged value + pending dot in the grid", async () => {
const store = useDbViewerStore.getState();
store.openTab("public", "users");
const tabId = useDbViewerStore.getState().activeTabId!;
store.setTabData(tabId, {
columns: [
{ 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 },
],
rows: [[1, "Alice"]],
total_rows: 1,
page: 1,
page_size: 50,
} as any);
render(
<DbViewerScreen
connectionId="c1"
onHome={() => {}}
onSettings={() => {}}
/>,
);
const cell = await waitFor(() => screen.getByText("Alice"));
fireEvent.click(cell);
fireEvent.keyDown(cell, { key: "Enter" });
// the editor's textarea is the last textbox (toolbar filter input is first)
const textboxes = screen.getAllByRole("textbox");
const input = textboxes[textboxes.length - 1]!;
fireEvent.change(input, { target: { value: "Alicia" } });
fireEvent.keyDown(input, { key: "Enter" });
// staged change carries the correct table (was the root-cause bug)
const staged = useDbViewerStore.getState().changesQueue[0];
expect(staged?.table).toBe("users");
expect(staged?.schema).toBe("public");
// grid cell shows the optimistic value + the pending dot (2nd match is the queue panel diff)
await waitFor(() => {
expect(screen.getAllByText("Alicia").length).toBeGreaterThanOrEqual(2);
});
expect(screen.getByTestId("pending-edit-dot")).toBeInTheDocument();
// committing the change clears the pending dot but keeps the value until refetch
act(() => {
useDbViewerStore
.getState()
.markChangeCommitted(
useDbViewerStore.getState().changesQueue[0].id,
);
});
await waitFor(() => {
expect(screen.queryByTestId("pending-edit-dot")).toBeNull();
});
expect(screen.getAllByText("Alicia").length).toBeGreaterThanOrEqual(2);
// clearing the queue clears the optimistic display
act(() => {
useDbViewerStore.getState().clearChanges();
});
await waitFor(() => {
expect(screen.getByText("Alice")).toBeInTheDocument();
});
expect(screen.queryByText("Alicia")).toBeNull();
});
it("deriveStagedValues maps queue updates to optimistic cell values", () => {
const queue = [
{
id: "ch-1", type: "update" as const, sql: "", schema: "public", table: "users",
primaryKey: { id: 1 }, oldData: { name: "Alice" }, newData: { name: "Alicia" },
status: "pending" as const, createdAt: 0,
},
];
const rows: unknown[][] = [[1, "Alice"], [2, "Bob"]];
const loc = (r: unknown[]) => ({ id: r[0] });
expect(deriveStagedValues(queue as any, "public", "users", rows, loc)).toEqual({
"0:name": "Alicia",
});
});
it("deriveStagedValues ignores failed/other-table changes and handles NULL", () => {
const queue = [
{
id: "ch-1", type: "update" as const, sql: "", schema: "public", table: "users",
primaryKey: { id: 1 }, oldData: { name: "Alice" }, newData: { name: null },
status: "pending" as const, createdAt: 0,
},
{
id: "ch-2", type: "update" as const, sql: "", schema: "public", table: "orders",
primaryKey: { id: 1 }, oldData: { x: 1 }, newData: { x: 2 },
status: "pending" as const, createdAt: 0,
},
{
id: "ch-3", type: "update" as const, sql: "", schema: "public", table: "users",
primaryKey: { id: 1 }, oldData: { name: "Alice" }, newData: { name: "X" },
status: "failed" as const, error: "boom", createdAt: 0,
},
];
const rows: unknown[][] = [[1, "Alice"]];
const loc = (r: unknown[]) => ({ id: r[0] });
expect(deriveStagedValues(queue as any, "public", "users", rows, loc)).toEqual({
"0:name": null,
});
});
it("derivePendingCellKeys only includes pending updates (dot clears on commit)", () => {
const queue = [
{
id: "ch-1", type: "update" as const, sql: "", schema: "public", table: "users",
primaryKey: { id: 1 }, oldData: { name: "Alice" }, newData: { name: "Alicia" },
status: "pending" as const, createdAt: 0,
},
{
id: "ch-2", type: "update" as const, sql: "", schema: "public", table: "users",
primaryKey: { id: 2 }, oldData: { name: "Bob" }, newData: { name: "Bobby" },
status: "committed" as const, createdAt: 0,
},
];
const rows: unknown[][] = [[1, "Alice"], [2, "Bob"]];
const loc = (r: unknown[]) => ({ id: r[0] });
expect(derivePendingCellKeys(queue as any, "public", "users", rows, loc)).toEqual({
"0:name": true,
});
});
it("renders the New Query button", () => { it("renders the New Query button", () => {
render( render(
<DbViewerScreen <DbViewerScreen
@@ -459,4 +595,230 @@ describe("DbViewerScreen", () => {
).not.toBeInTheDocument(), ).not.toBeInTheDocument(),
); );
}); });
it("disables Insert Row for a materialized-view tab", async () => {
useDbViewerStore.setState({
tables: [
{ name: "mat_users", schema: "public", table_type: "MATERIALIZED VIEW" },
],
tabs: [
{
id: "tab-mv",
schema: "public",
table: "mat_users",
page: 1,
pageSize: 50,
loading: false,
error: null,
data: mockQueryResult,
filterRules: [],
sortRules: [],
hiddenColumns: [],
smartSortApplied: true,
tabType: "table",
},
],
activeTabId: "tab-mv",
});
render(
<DbViewerScreen
connectionId="c1"
onHome={() => {}}
onSettings={() => {}}
/>,
);
await waitFor(() =>
expect(screen.queryByLabelText(/insert row/i)).toBeNull(),
);
});
it("refetches the active tab after a successful Commit All", async () => {
useUiStore.setState({ activeConnectionId: "c1" });
vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
const getTableData = vi
.spyOn(commands, "getTableData")
.mockResolvedValue({
columns: mockQueryResult.columns,
rows: [[2]],
total_rows: 1,
page: 1,
page_size: 50,
} as any);
useDbViewerStore.setState({
tabs: [
{
id: "tab-1",
schema: "public",
table: "users",
page: 1,
pageSize: 50,
loading: false,
error: null,
data: mockQueryResult,
filterRules: [],
sortRules: [],
hiddenColumns: [],
smartSortApplied: true,
tabType: "table",
},
],
activeTabId: "tab-1",
changesQueue: [],
changesPanelExpanded: true,
});
useDbViewerStore.getState().addChange({
type: "insert",
schema: "public",
table: "users",
newData: { id: 2, name: "Alice" },
description: "Insert row into users",
});
render(
<DbViewerScreen
connectionId="c1"
onHome={() => {}}
onSettings={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /commit all/i }));
await waitFor(() => expect(getTableData).toHaveBeenCalledTimes(1));
});
it("fetches enum labels and FK reference rows for the active table tab", async () => {
const getEnums = vi
.spyOn(commands, "getEnums")
.mockResolvedValue([
{
name: "user_role",
schema: "public",
labels: ["admin", "user"],
},
]);
const getTableData = vi
.spyOn(commands, "getTableData")
.mockResolvedValue({
columns: [
{
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,
},
],
rows: [[1], [2]],
total_rows: 2,
page: 1,
page_size: 50,
} as any);
useDbViewerStore.setState({
tabs: [
{
id: "tab-1",
schema: "public",
table: "users",
page: 1,
pageSize: 50,
loading: false,
error: null,
data: {
columns: [
{
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: "user_id",
data_type: "integer",
is_nullable: true,
is_pk: false,
is_fk: true,
fk_ref: ["users", "id"],
default_value: null,
editable: true,
is_generated: false,
},
{
name: "role",
data_type: "user_role",
is_nullable: true,
is_pk: false,
is_fk: false,
fk_ref: null,
default_value: null,
editable: true,
is_generated: false,
},
],
rows: [[1, 2, "admin"]],
total_rows: 1,
page: 1,
page_size: 50,
} as any,
filterRules: [],
sortRules: [],
hiddenColumns: [],
smartSortApplied: true,
tabType: "table",
},
],
activeTabId: "tab-1",
});
render(
<DbViewerScreen
connectionId="c1"
onHome={() => {}}
onSettings={() => {}}
/>,
);
// Enum labels are fetched for the tab's schema (cached per schema).
await waitFor(() =>
expect(getEnums).toHaveBeenCalledWith("c1", "public"),
);
// FK reference rows are fetched from the referenced table (page 1, 50).
await waitFor(() =>
expect(getTableData).toHaveBeenCalledWith(
"c1",
"public",
"users",
1,
50,
),
);
});
it("pickDisplayColumn prefers name-like columns over the ref column", () => {
const cols = [
{ name: "id", data_type: "integer" },
{ name: "email", data_type: "text" },
{ name: "name", data_type: "text" },
];
expect(pickDisplayColumn(cols, "id")).toBe("name");
expect(pickDisplayColumn(cols, "id", "email")).toBe("email");
});
it("pickDisplayColumn falls back to the ref column when nothing is name-like", () => {
const cols = [{ name: "id", data_type: "integer" }];
expect(pickDisplayColumn(cols, "id")).toBe("id");
});
}); });
+348 -1
View File
@@ -16,6 +16,7 @@ import { TableTree } from "./TableTree";
import { ObjectExplorerPage } from "./ObjectExplorerPage"; import { ObjectExplorerPage } from "./ObjectExplorerPage";
import { TabBar } from "./TabBar"; import { TabBar } from "./TabBar";
import { VirtualDataGrid } from "../grid/VirtualDataGrid"; import { VirtualDataGrid } from "../grid/VirtualDataGrid";
import { RowDetailDrawer } from "../grid/RowDetailDrawer";
import { TableControls } from "./TableControls"; import { TableControls } from "./TableControls";
import { EditConnectionModal } from "./EditConnectionModal"; import { EditConnectionModal } from "./EditConnectionModal";
import { useDbConnection } from "../../hooks/useDbConnection"; import { useDbConnection } from "../../hooks/useDbConnection";
@@ -29,6 +30,124 @@ import { SchemaVisualizerPage } from "./SchemaVisualizerPage";
import { QueriesPanel } from "../queries/QueriesPanel"; import { QueriesPanel } from "../queries/QueriesPanel";
import { useQueryStore } from "../../stores/queryStore"; import { useQueryStore } from "../../stores/queryStore";
import * as cmd from "../../lib/commands"; import * as cmd from "../../lib/commands";
import type { EnumInfo } from "../../lib/types";
import type { FkOption } from "../grid/CellEditor";
import type { QueueItem } from "../../stores/dbViewerStore";
/**
* Derive optimistic staged cell values from the changes queue for a table
* tab, keyed `${rowIndex}:${colName}` the staged value (null = NULL).
* Rows are matched to queue items via the row locator (PK or ctid/rowid).
* Pending + committed updates count (survive until refetch); Clear All
* empties the queue so the optimistic display vanishes.
*/
export function deriveStagedValues(
changesQueue: QueueItem[],
schema: string,
table: string,
rows: unknown[][],
getLocator: (row: unknown[]) => Record<string, unknown>,
): Record<string, string | null> {
const map: Record<string, string | null> = {};
const updates = changesQueue.filter(
(c) =>
c.type === "update" &&
(c.status === "pending" || c.status === "committed") &&
c.schema === schema &&
c.table === table &&
c.primaryKey &&
c.newData,
);
if (updates.length === 0) return map;
rows.forEach((row, rowIdx) => {
const loc = getLocator(row);
for (const c of updates) {
const pk = c.primaryKey!;
const matches = Object.entries(pk).every(
([k, v]) => String(loc[k]) === String(v),
);
if (!matches) continue;
const colName = Object.keys(c.newData!)[0];
if (!colName) continue;
map[`${rowIdx}:${colName}`] =
(c.newData![colName] as string | null) ?? null;
}
});
return map;
}
/**
* Keys of cells with a PENDING update only drives the amber pending dot.
* Once a change is committed the dot clears even though the optimistic value
* (from `deriveStagedValues`) stays until the refetch lands.
*/
export function derivePendingCellKeys(
changesQueue: QueueItem[],
schema: string,
table: string,
rows: unknown[][],
getLocator: (row: unknown[]) => Record<string, unknown>,
): Record<string, boolean> {
const keys: Record<string, boolean> = {};
const updates = changesQueue.filter(
(c) =>
c.type === "update" &&
c.status === "pending" &&
c.schema === schema &&
c.table === table &&
c.primaryKey &&
c.newData,
);
if (updates.length === 0) return keys;
rows.forEach((row, rowIdx) => {
const loc = getLocator(row);
for (const c of updates) {
const pk = c.primaryKey!;
const matches = Object.entries(pk).every(
([k, v]) => String(loc[k]) === String(v),
);
if (!matches) continue;
const colName = Object.keys(c.newData!)[0];
if (!colName) continue;
keys[`${rowIdx}:${colName}`] = true;
}
});
return keys;
}
/**
* Pick a human-friendly display column for FK option labels from the
* referenced table's columns: prefer name-like columns, else the first
* text-ish column that isn't the ref column, else the ref column itself.
*/
export function pickDisplayColumn(
columns: { name: string; data_type: string }[],
refCol: string,
preferred?: string,
): string {
if (preferred && columns.some((c) => c.name === preferred)) return preferred;
const nameLike = [
"name",
"title",
"label",
"username",
"email",
"full_name",
"display_name",
"first_name",
"last_name",
"description",
];
for (const n of nameLike) {
if (columns.some((c) => c.name === n)) return n;
}
const textish = columns.find(
(c) =>
c.name !== refCol &&
/text|char|name|uuid/i.test(c.data_type),
);
return textish ? textish.name : refCol;
}
export interface DbViewerScreenProps { export interface DbViewerScreenProps {
connectionId: string; connectionId: string;
@@ -48,6 +167,7 @@ export function DbViewerScreen({
const [queriesPanelWidth, setQueriesPanelWidth] = useState(280); const [queriesPanelWidth, setQueriesPanelWidth] = useState(280);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set()); const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
const [rowDetailIdx, setRowDetailIdx] = useState<number | null>(null);
const [editModalOpen, setEditModalOpen] = useState(false); const [editModalOpen, setEditModalOpen] = useState(false);
const [destructiveQuery, setDestructiveQuery] = useState<string | null>(null); const [destructiveQuery, setDestructiveQuery] = useState<string | null>(null);
const connections = useConnectionStore((s) => s.connections); const connections = useConnectionStore((s) => s.connections);
@@ -84,6 +204,19 @@ export function DbViewerScreen({
const filterRules = activeTab?.filterRules ?? []; const filterRules = activeTab?.filterRules ?? [];
const sortRules = activeTab?.sortRules ?? []; const sortRules = activeTab?.sortRules ?? [];
const hiddenColumns = new Set(activeTab?.hiddenColumns ?? []); const hiddenColumns = new Set(activeTab?.hiddenColumns ?? []);
const changesQueue = useDbViewerStore((s) => s.changesQueue);
const tables = useDbViewerStore((s) => s.tables);
const stageCellEdit = useDbViewerStore((s) => s.stageCellEdit);
const isMatview =
activeTab && activeTab.tabType === "table"
? tables.some(
(t) =>
t.schema === activeTab.schema &&
t.name === activeTab.table &&
t.table_type === "MATERIALIZED VIEW",
)
: false;
const setTabData = useDbViewerStore((s) => s.setTabData); const setTabData = useDbViewerStore((s) => s.setTabData);
const setTabError = useDbViewerStore((s) => s.setTabError); const setTabError = useDbViewerStore((s) => s.setTabError);
@@ -96,6 +229,18 @@ export function DbViewerScreen({
const setCurrentSchema = useDbViewerStore((s) => s.setCurrentSchema); const setCurrentSchema = useDbViewerStore((s) => s.setCurrentSchema);
const fetchingRef = useRef<Set<string>>(new Set()); const fetchingRef = useRef<Set<string>>(new Set());
// CellEditor options for the active table tab: PG enum labels (cached per
// connection+schema) + FK reference rows (page 1, 50 per FK column).
const [editorOptions, setEditorOptions] = useState<{
enums: Record<string, string[]>;
fks: Record<string, FkOption[]>;
fkPlaceholders: Record<string, string>;
} | null>(null);
const enumCacheRef = useRef<Map<string, EnumInfo[]>>(new Map());
// Key identifying the (connection, tab, schema) the options were fetched for;
// guards against refetching on every render while data updates in place.
const editorOptionsKeyRef = useRef<string>("");
const fetchData = useCallback( const fetchData = useCallback(
async (tab: NonNullable<typeof activeTab>) => { async (tab: NonNullable<typeof activeTab>) => {
if (fetchingRef.current.has(tab.id)) return; if (fetchingRef.current.has(tab.id)) return;
@@ -139,6 +284,31 @@ export function DbViewerScreen({
} }
} }
const handleStageEdit = useCallback(
(payload: {
type: "update";
schema: string;
table: string;
primaryKey: Record<string, unknown>;
oldData: Record<string, unknown>;
newData: Record<string, unknown>;
}) => {
if (!activeTab) return;
const { type: _, ...rest } = payload;
stageCellEdit({ tabId: activeTab.id, ...rest });
},
[activeTab, stageCellEdit],
);
const handleOpenRowDetail = useCallback((rowIndex: number) => {
setRowDetailIdx(rowIndex);
}, []);
const handleCommitted = useCallback(() => {
if (!activeTab) return;
fetchData(activeTab);
}, [activeTab, fetchData]);
// Read the active tab from the store directly so the Monaco keybinding action // Read the active tab from the store directly so the Monaco keybinding action
// (which keeps the first onRun closure) always sees the latest query text. // (which keeps the first onRun closure) always sees the latest query text.
const handleRunQuery = useCallback(() => { const handleRunQuery = useCallback(() => {
@@ -243,6 +413,108 @@ export function DbViewerScreen({
fetchData(activeTab); fetchData(activeTab);
}, [activeTab, fetchData]); }, [activeTab, fetchData]);
// Feed the grid's CellEditor with enum labels + FK reference rows for the
// active table tab. Fetched once per tab/schema (enums additionally cached
// per connection+schema across tabs); a failed fetch for one FK column is
// skipped without breaking the tab. Never refetches on in-place data updates.
useEffect(() => {
const key =
activeTab && activeTab.tabType === "table" && activeTab.data
? `${connectionId}:${activeTab.id}:${activeTab.schema}`
: "";
if (key === editorOptionsKeyRef.current) return;
editorOptionsKeyRef.current = key;
if (!key || !activeTab || !activeTab.data) {
setEditorOptions(null);
return;
}
const cols = activeTab.data.columns;
const tab = activeTab;
void (async () => {
const enums: Record<string, string[]> = {};
const fks: Record<string, FkOption[]> = {};
const fkPlaceholders: Record<string, string> = {};
// PG enums: fetched once per connection+schema, reused across tabs.
const cacheKey = `${connectionId}:${tab.schema}`;
let enumList = enumCacheRef.current.get(cacheKey);
if (!enumList) {
try {
enumList = await cmd.getEnums(connectionId, tab.schema);
enumCacheRef.current.set(cacheKey, enumList);
} catch {
enumList = [];
}
}
for (const col of cols) {
const match = enumList.find((e) => e.name === col.data_type);
if (match) enums[col.name] = match.labels;
}
// FK options: referenced rows (page 1, 50) per FK column.
const fkCols = cols.filter((c) => c.is_fk && c.fk_ref);
await Promise.all(
fkCols.map(async (col) => {
const [refTable, refCol] = col.fk_ref!;
try {
const result = await cmd.getTableData(
connectionId,
tab.schema,
refTable,
1,
50,
);
const refIdx = result.columns.findIndex(
(c) => c.name === refCol,
);
if (refIdx >= 0) {
const displayCol = pickDisplayColumn(
result.columns,
refCol,
);
const displayIdx =
displayCol === refCol
? refIdx
: result.columns.findIndex(
(c) => c.name === displayCol,
);
fks[col.name] = result.rows.map((row) => {
const refValue = String(row[refIdx]);
const dispValue =
displayIdx >= 0 && displayIdx !== refIdx
? String(row[displayIdx])
: "";
return {
value: refValue,
label:
dispValue && dispValue !== refValue
? `${refValue}${dispValue}`
: refValue,
// Referenced-row cells for the FK-reference-style
// one-row dropdown (first 5 columns).
cells: result.columns
.slice(0, 5)
.map((c, i) => ({
name: c.name,
value: String(row[i] ?? ""),
})),
};
});
fkPlaceholders[col.name] = `Search ${refTable}`;
}
} catch {
// Skip this FK column; the cell keeps the plain editor.
}
}),
);
// Apply only if no newer fetch superseded this one (tab/schema switched).
if (editorOptionsKeyRef.current === key) {
setEditorOptions({ enums, fks, fkPlaceholders });
}
})();
}, [activeTab, connectionId]);
// Smart default sort: apply once when data first loads for a tab // Smart default sort: apply once when data first loads for a tab
useEffect(() => { useEffect(() => {
if (!activeTab) return; if (!activeTab) return;
@@ -630,9 +902,48 @@ const onQueriesPanelResizeStart = useCallback(
const activeTable = activeTab?.table ?? ""; const activeTable = activeTab?.table ?? "";
function renderQueryWorkspace() { function renderQueryWorkspace() {
const getLocator = (row: unknown[]) => {
const pkCol = columns.find((c) => c.is_pk);
if (pkCol) {
const pkIndex = columns.findIndex(
(c) => c.name === pkCol.name,
);
return { [pkCol.name]: row[pkIndex] };
}
const dbType = currentConnection?.db_type ?? "postgresql";
const locatorIndex = columns.length;
if (dbType === "sqlite") {
return { rowid: row[locatorIndex] };
}
return { ctid: row[locatorIndex] };
};
// Staged cell values derived from the changes queue (single source of
// truth): keyed `${rowIndex}:${colName}` → optimistic value. Pending +
// committed updates survive until refetch; Clear All empties the queue
// so the optimistic display and pending dots vanish immediately.
const stagedValues = activeTab?.data
? deriveStagedValues(
changesQueue,
activeTab.schema,
activeTab.table,
activeTab.data.rows,
getLocator,
)
: {};
const pendingKeys = activeTab?.data
? derivePendingCellKeys(
changesQueue,
activeTab.schema,
activeTab.table,
activeTab.data.rows,
getLocator,
)
: {};
return ( return (
<div className="flex-1 w-0 flex flex-col min-w-0 overflow-hidden"> <div className="flex-1 w-0 flex flex-col min-w-0 overflow-hidden">
<TabBar /> <TabBar onCommitted={handleCommitted} />
{!activeTab ? ( {!activeTab ? (
<div className="flex-1 flex flex-col items-center justify-center gap-2 text-text-muted"> <div className="flex-1 flex flex-col items-center justify-center gap-2 text-text-muted">
{currentView === "queries" ? ( {currentView === "queries" ? (
@@ -794,16 +1105,29 @@ const onQueriesPanelResizeStart = useCallback(
) )
} }
variant="query" variant="query"
isMatview={isMatview}
/> />
)} )}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden"> <div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<VirtualDataGrid <VirtualDataGrid
connectionId={connectionId} connectionId={connectionId}
schema={activeSchema} schema={activeSchema}
table={activeTable}
rows={processedRows} rows={processedRows}
columns={columns} columns={columns}
hiddenColumns={hiddenColumns} hiddenColumns={hiddenColumns}
selectedRows={selectedRows} selectedRows={selectedRows}
dbType={currentConnection?.db_type ?? "postgresql"}
tabType={activeTab?.tabType ?? "table"}
getLocator={getLocator}
onStageEdit={isMatview ? undefined : handleStageEdit}
onOpenRowDetail={handleOpenRowDetail}
readOnly={isMatview}
enumValues={editorOptions?.enums}
fkOptions={editorOptions?.fks}
fkPlaceholders={editorOptions?.fkPlaceholders}
stagedValues={stagedValues}
pendingKeys={pendingKeys}
onToggleRow={(rowIndex) => { onToggleRow={(rowIndex) => {
setSelectedRows( setSelectedRows(
(prev) => { (prev) => {
@@ -934,16 +1258,29 @@ const onQueriesPanelResizeStart = useCallback(
onClearSelection={() => onClearSelection={() =>
setSelectedRows(new Set()) setSelectedRows(new Set())
} }
isMatview={isMatview}
/> />
)} )}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden"> <div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<VirtualDataGrid <VirtualDataGrid
connectionId={connectionId} connectionId={connectionId}
schema={activeSchema} schema={activeSchema}
table={activeTable}
rows={processedRows} rows={processedRows}
columns={columns} columns={columns}
hiddenColumns={hiddenColumns} hiddenColumns={hiddenColumns}
selectedRows={selectedRows} selectedRows={selectedRows}
dbType={currentConnection?.db_type ?? "postgresql"}
tabType={activeTab?.tabType ?? "table"}
getLocator={getLocator}
onStageEdit={isMatview ? undefined : handleStageEdit}
onOpenRowDetail={handleOpenRowDetail}
readOnly={isMatview}
enumValues={editorOptions?.enums}
fkOptions={editorOptions?.fks}
fkPlaceholders={editorOptions?.fkPlaceholders}
stagedValues={stagedValues}
pendingKeys={pendingKeys}
onToggleRow={(rowIndex) => { onToggleRow={(rowIndex) => {
setSelectedRows((prev) => { setSelectedRows((prev) => {
const next = new Set( const next = new Set(
@@ -978,6 +1315,16 @@ const onQueriesPanelResizeStart = useCallback(
</div> </div>
</> </>
)} )}
{rowDetailIdx !== null && activeTab?.data && (
<RowDetailDrawer
columns={activeTab.data.columns}
row={activeTab.data.rows[rowDetailIdx]}
onClose={() => setRowDetailIdx(null)}
onCopy={(value) =>
navigator.clipboard.writeText(value).catch(() => {})
}
/>
)}
</div> </div>
); );
} }
@@ -0,0 +1,27 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { FilterBuilder } from "./FilterBuilder";
import type { ColumnInfo } from "../../lib/types";
const cols: ColumnInfo[] = [
{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: false, is_generated: false },
{ name: "name", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
];
describe("FilterBuilder", () => {
it("drops a column chip into the drop zone to create a rule with a type-aware operator", () => {
const onChange = vi.fn();
render(<FilterBuilder columns={cols} rules={[]} onChange={onChange} />);
fireEvent.click(screen.getByText("name"));
expect(onChange).toHaveBeenCalledWith([
expect.objectContaining({ column: "name", operator: "contains" }),
]);
});
it("removing a rule calls onChange without it", () => {
const onChange = vi.fn();
const rules = [{ id: "r1", column: "name", operator: "contains" as const, value: "Al" }];
render(<FilterBuilder columns={cols} rules={rules} onChange={onChange} />);
fireEvent.click(screen.getByLabelText("Remove filter name"));
expect(onChange).toHaveBeenCalledWith([]);
});
});
+132
View File
@@ -0,0 +1,132 @@
import { useState } from "react";
import { DndContext, useDraggable, useDroppable } from "@dnd-kit/core";
import { X } from "lucide-react";
import { defaultFilterOperator } from "../grid/gridEditability";
import type { ColumnInfo } from "../../lib/types";
import type { FilterRule, FilterOperator } from "../../stores/dbViewerStore";
interface Props {
columns: ColumnInfo[];
rules: FilterRule[];
onChange: (rules: FilterRule[]) => void;
}
function Chip({ col, onAdd }: { col: ColumnInfo; onAdd: () => void }) {
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
id: `col-${col.name}`,
data: { column: col },
});
return (
<button
ref={setNodeRef}
{...attributes}
{...listeners}
onClick={onAdd}
className={`px-2 py-1 text-xs rounded border border-border bg-surface text-text hover:border-accent ${
isDragging ? "opacity-50" : ""
}`}
>
{col.name}
</button>
);
}
const OPERATORS: FilterOperator[] = [
"eq",
"neq",
"contains",
"starts",
"ends",
"gt",
"lt",
"null",
"notnull",
];
export function FilterBuilder({ columns, rules, onChange }: Props) {
const [val, setVal] = useState<Record<string, string>>({});
const addRule = (col: ColumnInfo) => {
const op = defaultFilterOperator(col.data_type);
onChange([
...rules,
{
id: `f-${Date.now()}-${col.name}`,
column: col.name,
operator: op,
value: "",
},
]);
};
const { setNodeRef, isOver } = useDroppable({ id: "filter-dropzone" });
const remove = (id: string) => onChange(rules.filter((r) => r.id !== id));
const update = (id: string, patch: Partial<FilterRule>) =>
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
return (
<DndContext
onDragEnd={(e) => {
const id = e.active.id as string;
const colName = id.replace(/^col-/, "");
const col = columns.find((c) => c.name === colName);
if (col && e.over?.id === "filter-dropzone") addRule(col);
}}
>
<div className="flex flex-wrap gap-1 mb-2">
{columns.map((c) => (
<Chip key={c.name} col={c} onAdd={() => addRule(c)} />
))}
</div>
<div
ref={setNodeRef}
className={`min-h-[40px] border border-dashed rounded p-2 space-y-1 ${
isOver ? "border-accent bg-surface" : "border-border"
}`}
>
{rules.length === 0 && (
<span className="text-xs text-text-muted">
Drop columns here to add filters
</span>
)}
{rules.map((r) => (
<div key={r.id} className="flex items-center gap-2 text-xs">
<span className="text-text font-semibold">{r.column}</span>
<select
value={r.operator}
onChange={(e) =>
update(r.id, { operator: e.target.value as FilterOperator })
}
className="bg-surface border border-border rounded px-1"
>
{OPERATORS.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
{!["null", "notnull"].includes(r.operator) && (
<input
value={val[r.id] ?? r.value}
onChange={(e) => {
setVal({ ...val, [r.id]: e.target.value });
update(r.id, { value: e.target.value });
}}
className="bg-surface border border-border rounded px-1 flex-1"
placeholder="value"
/>
)}
<button
aria-label={`Remove filter ${r.column}`}
onClick={() => remove(r.id)}
className="text-text-muted hover:text-red-400"
>
<X size={12} />
</button>
</div>
))}
</div>
</DndContext>
);
}
@@ -0,0 +1,192 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ObjectExplorerPage } from "./ObjectExplorerPage";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import * as commands from "../../lib/commands";
describe("ObjectExplorerPage", () => {
beforeEach(() => {
vi.restoreAllMocks();
useDbViewerStore.getState().reset();
useDbViewerStore.setState({
schemas: ["public"],
currentSchema: "public",
});
vi.spyOn(commands, "getFunctions").mockResolvedValue([]);
vi.spyOn(commands, "getIndexes").mockResolvedValue([]);
vi.spyOn(commands, "getConstraints").mockResolvedValue([]);
vi.spyOn(commands, "getTriggers").mockResolvedValue([]);
vi.spyOn(commands, "getSequences").mockResolvedValue([]);
vi.spyOn(commands, "getEnums").mockResolvedValue([]);
vi.spyOn(commands, "getExtensions").mockResolvedValue([]);
});
it("renders functions by default", async () => {
vi.spyOn(commands, "getFunctions").mockResolvedValue([
{
name: "add_one",
schema: "public",
return_type: "int",
argument_types: ["int"],
argument_names: ["x"],
argument_modes: ["IN"],
language: "sql",
source: "SELECT $1 + 1",
kind: "f",
},
]);
render(<ObjectExplorerPage connectionId="c1" />);
await waitFor(() =>
expect(screen.getByText("add_one(int)")).toBeInTheDocument(),
);
});
it("functions type filters to kind === 'f'", async () => {
vi.spyOn(commands, "getFunctions").mockResolvedValue([
{
name: "do_thing",
schema: "public",
return_type: "void",
argument_types: [],
argument_names: [],
argument_modes: [],
language: "plpgsql",
source: "BEGIN END",
kind: "p",
},
{
name: "calc",
schema: "public",
return_type: "int",
argument_types: [],
argument_names: [],
argument_modes: [],
language: "sql",
source: "SELECT 1",
kind: "f",
},
]);
render(<ObjectExplorerPage connectionId="c1" />);
await waitFor(() => expect(screen.getByText("calc")).toBeInTheDocument());
expect(screen.queryByText("do_thing")).not.toBeInTheDocument();
});
it("indexes type fetches getIndexes and renders the index name + detail", async () => {
const user = userEvent.setup();
const getIndexes = vi.spyOn(commands, "getIndexes").mockResolvedValue([
{
name: "idx_users_email",
schema: "public",
table: "users",
definition: "CREATE INDEX idx_users_email ON users USING btree (email);",
is_unique: true,
method: "btree",
columns: ["email"],
size_bytes: 8192,
tablespace: null,
},
]);
render(<ObjectExplorerPage connectionId="c1" />);
await user.click(screen.getByLabelText("Object type"));
await user.click(screen.getByText("Indexes"));
await waitFor(() =>
expect(screen.getByText("idx_users_email")).toBeInTheDocument(),
);
expect(getIndexes).toHaveBeenCalledWith("c1", "public");
await user.click(screen.getByText("idx_users_email"));
await waitFor(() => {
expect(screen.getByText("Index")).toBeInTheDocument();
expect(screen.getAllByText("btree").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("8192")).toBeInTheDocument();
});
});
it("constraints type fetches getConstraints and renders the constraint name + detail", async () => {
const user = userEvent.setup();
const getConstraints = vi.spyOn(commands, "getConstraints").mockResolvedValue([
{
name: "chk_users_positive",
schema: "public",
table: "users",
contype: "CHECK",
definition: "CHECK (age > 0)",
deferrable: false,
validated: true,
columns: ["age"],
},
]);
render(<ObjectExplorerPage connectionId="c1" />);
await user.click(screen.getByLabelText("Object type"));
await user.click(screen.getByText("Constraints"));
await waitFor(() =>
expect(screen.getByText("chk_users_positive")).toBeInTheDocument(),
);
expect(getConstraints).toHaveBeenCalledWith("c1", "public");
await user.click(screen.getByText("chk_users_positive"));
await waitFor(() => {
expect(screen.getByText("Constraint")).toBeInTheDocument();
expect(screen.getAllByText("CHECK").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("Deferrable")).toBeInTheDocument();
});
});
it("shows 'No indexes found' when getIndexes returns []", async () => {
const user = userEvent.setup();
vi.spyOn(commands, "getIndexes").mockResolvedValue([]);
render(<ObjectExplorerPage connectionId="c1" />);
await user.click(screen.getByLabelText("Object type"));
await user.click(screen.getByText("Indexes"));
await waitFor(() =>
expect(screen.getByText("No indexes found")).toBeInTheDocument(),
);
});
it("shows an error message when getIndexes rejects", async () => {
const user = userEvent.setup();
vi.spyOn(commands, "getIndexes").mockRejectedValue(new Error("boom"));
render(<ObjectExplorerPage connectionId="c1" />);
await user.click(screen.getByLabelText("Object type"));
await user.click(screen.getByText("Indexes"));
await waitFor(() =>
expect(screen.getByText("boom")).toBeInTheDocument(),
);
});
it("procedures type filters getFunctions to kind === 'p'", async () => {
const user = userEvent.setup();
vi.spyOn(commands, "getFunctions").mockResolvedValue([
{
name: "do_thing",
schema: "public",
return_type: "void",
argument_types: [],
argument_names: [],
argument_modes: [],
language: "plpgsql",
source: "BEGIN END",
kind: "p",
},
{
name: "calc",
schema: "public",
return_type: "int",
argument_types: [],
argument_names: [],
argument_modes: [],
language: "sql",
source: "SELECT 1",
kind: "f",
},
]);
render(<ObjectExplorerPage connectionId="c1" />);
await user.click(screen.getByLabelText("Object type"));
await user.click(screen.getByText("Procedures"));
await waitFor(() =>
expect(screen.getByText("do_thing")).toBeInTheDocument(),
);
expect(screen.queryByText("calc")).not.toBeInTheDocument();
});
});
+237 -13
View File
@@ -1,9 +1,12 @@
import { useEffect, useState, useMemo, useCallback, useRef, cloneElement } from "react"; import { useEffect, useState, useMemo, useCallback, useRef, cloneElement } from "react";
import { import {
BookMarked,
ChevronRight, ChevronRight,
FunctionSquare, FunctionSquare,
GitBranch, GitBranch,
ListChecks,
ListOrdered, ListOrdered,
SquareFunction,
Tag, Tag,
Puzzle, Puzzle,
Search, Search,
@@ -19,6 +22,8 @@ import type {
SequenceInfo, SequenceInfo,
EnumInfo, EnumInfo,
ExtensionInfo, ExtensionInfo,
IndexInfo,
ConstraintInfo,
} from "../../lib/types"; } from "../../lib/types";
export type ObjectType = export type ObjectType =
@@ -26,7 +31,10 @@ export type ObjectType =
| "triggers" | "triggers"
| "sequences" | "sequences"
| "enums" | "enums"
| "extensions"; | "extensions"
| "indexes"
| "constraints"
| "procedures";
interface ObjectExplorerPageProps { interface ObjectExplorerPageProps {
connectionId: string; connectionId: string;
@@ -38,6 +46,9 @@ const TYPE_LABELS: Record<ObjectType, string> = {
sequences: "Sequences", sequences: "Sequences",
enums: "Enums", enums: "Enums",
extensions: "Extensions", extensions: "Extensions",
indexes: "Indexes",
constraints: "Constraints",
procedures: "Procedures",
}; };
const OBJECT_TYPE_OPTIONS = (Object.keys(TYPE_LABELS) as ObjectType[]).map( const OBJECT_TYPE_OPTIONS = (Object.keys(TYPE_LABELS) as ObjectType[]).map(
@@ -50,8 +61,21 @@ const SINGULAR_LABELS: Record<ObjectType, string> = {
sequences: "sequence", sequences: "sequence",
enums: "enum", enums: "enum",
extensions: "extension", extensions: "extension",
indexes: "index",
constraints: "constraint",
procedures: "procedure",
}; };
/** Natural plural for empty-state copy, derived from SINGULAR_LABELS with known irregulars mapped explicitly. */
function emptyPlural(type: ObjectType): string {
const singular = SINGULAR_LABELS[type];
const irregulars: Record<string, string> = {
index: "indexes",
constraint: "constraints",
};
return irregulars[singular] ?? `${singular}s`;
}
const ICONS: Record<ObjectType, React.ReactNode> = { const ICONS: Record<ObjectType, React.ReactNode> = {
functions: ( functions: (
<FunctionSquare size={14} className="text-text-muted shrink-0" /> <FunctionSquare size={14} className="text-text-muted shrink-0" />
@@ -60,6 +84,9 @@ const ICONS: Record<ObjectType, React.ReactNode> = {
sequences: <ListOrdered size={14} className="text-text-muted shrink-0" />, sequences: <ListOrdered size={14} className="text-text-muted shrink-0" />,
enums: <Tag size={14} className="text-text-muted shrink-0" />, enums: <Tag size={14} className="text-text-muted shrink-0" />,
extensions: <Puzzle size={14} className="text-text-muted shrink-0" />, extensions: <Puzzle size={14} className="text-text-muted shrink-0" />,
indexes: <BookMarked size={14} className="text-text-muted shrink-0" />,
constraints: <ListChecks size={14} className="text-text-muted shrink-0" />,
procedures: <SquareFunction size={14} className="text-text-muted shrink-0" />,
}; };
type AnyObject = type AnyObject =
@@ -67,7 +94,9 @@ type AnyObject =
| TriggerInfo | TriggerInfo
| SequenceInfo | SequenceInfo
| EnumInfo | EnumInfo
| ExtensionInfo; | ExtensionInfo
| IndexInfo
| ConstraintInfo;
/** Build a unique key per item. Functions use their signature to disambiguate overloads. */ /** Build a unique key per item. Functions use their signature to disambiguate overloads. */
function itemKey(item: AnyObject): string { function itemKey(item: AnyObject): string {
@@ -487,10 +516,7 @@ function SyntaxCode({
); );
} }
function renderDetail(type: ObjectType, item: AnyObject) { function renderFunctionDetail(f: FunctionInfo) {
switch (type) {
case "functions": {
const f = item as FunctionInfo;
return ( return (
<div> <div>
<div className="border-b border-border px-4 py-2"> <div className="border-b border-border px-4 py-2">
@@ -590,6 +616,191 @@ function renderDetail(type: ObjectType, item: AnyObject) {
)} )}
</div> </div>
); );
}
function renderDetail(type: ObjectType, item: AnyObject) {
switch (type) {
case "functions":
return renderFunctionDetail(item as FunctionInfo);
case "procedures":
return renderFunctionDetail(item as FunctionInfo);
case "indexes": {
const idx = item as IndexInfo;
return (
<div>
<div className="border-b border-border px-4 py-2">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Index
</span>
</div>
<div className="border-b border-border flex flex-row">
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Table
</span>
<span className="text-sm text-text font-mono">
{idx.table}
</span>
</div>
<div className="px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Method
</span>
<span className="text-sm text-accent font-mono">
{idx.method}
</span>
</div>
</div>
<div className="border-b border-border flex flex-row">
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Unique
</span>
<span
className={`text-sm ${idx.is_unique ? "text-emerald-400" : "text-text-muted"}`}
>
{idx.is_unique ? "Yes" : "No"}
</span>
</div>
<div className="px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Size
</span>
<span className="text-sm text-text font-mono">
{idx.size_bytes ?? "-"}
</span>
</div>
</div>
{idx.columns.length > 0 && (
<>
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Columns
</span>
<span className="text-[10px] text-text-subtle">
{idx.columns.length}
</span>
</div>
{idx.columns.map((col, i) => (
<div
key={i}
className="border-b border-border px-4 py-2 flex items-center"
>
<span className="text-xs text-text-muted w-12 shrink-0 font-mono">
#{i + 1}
</span>
<span className="text-sm text-accent font-mono">
{col}
</span>
</div>
))}
</>
)}
{idx.definition && (
<>
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Definition
</span>
<span className="text-[10px] text-text-subtle">
SQL
</span>
</div>
<SyntaxCode source={idx.definition} />
</>
)}
</div>
);
}
case "constraints": {
const c = item as ConstraintInfo;
return (
<div>
<div className="border-b border-border px-4 py-2">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Constraint
</span>
</div>
<div className="border-b border-border flex flex-row">
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Type
</span>
<span className="text-sm text-accent font-mono">
{c.contype}
</span>
</div>
<div className="px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Table
</span>
<span className="text-sm text-text font-mono">
{c.table}
</span>
</div>
</div>
<div className="border-b border-border flex flex-row">
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Deferrable
</span>
<span
className={`text-sm ${c.deferrable ? "text-amber-400" : "text-text-muted"}`}
>
{c.deferrable ? "Yes" : "No"}
</span>
</div>
<div className="px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Validated
</span>
<span
className={`text-sm ${c.validated ? "text-emerald-400" : "text-text-muted"}`}
>
{c.validated ? "Yes" : "No"}
</span>
</div>
</div>
{c.columns.length > 0 && (
<>
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Columns
</span>
<span className="text-[10px] text-text-subtle">
{c.columns.length}
</span>
</div>
{c.columns.map((col, i) => (
<div
key={i}
className="border-b border-border px-4 py-2 flex items-center"
>
<span className="text-xs text-text-muted w-12 shrink-0 font-mono">
#{i + 1}
</span>
<span className="text-sm text-accent font-mono">
{col}
</span>
</div>
))}
</>
)}
{c.definition && (
<>
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Definition
</span>
<span className="text-[10px] text-text-subtle">
SQL
</span>
</div>
<SyntaxCode source={c.definition} />
</>
)}
</div>
);
} }
case "triggers": { case "triggers": {
const t = item as TriggerInfo; const t = item as TriggerInfo;
@@ -867,10 +1078,15 @@ export function ObjectExplorerPage({ connectionId }: ObjectExplorerPageProps) {
if (type === "extensions") { if (type === "extensions") {
result = await cmd.getExtensions(connectionId); result = await cmd.getExtensions(connectionId);
} else if (type === "functions") { } else if (type === "functions") {
result = await cmd.getFunctions( result = (await cmd.getFunctions(
connectionId, connectionId,
currentSchema ?? undefined, currentSchema ?? undefined,
); )).filter((f) => f.kind === "f");
} else if (type === "procedures") {
result = (await cmd.getFunctions(
connectionId,
currentSchema ?? undefined,
)).filter((f) => f.kind === "p");
} else if (type === "triggers") { } else if (type === "triggers") {
result = await cmd.getTriggers( result = await cmd.getTriggers(
connectionId, connectionId,
@@ -886,6 +1102,16 @@ export function ObjectExplorerPage({ connectionId }: ObjectExplorerPageProps) {
connectionId, connectionId,
currentSchema ?? undefined, currentSchema ?? undefined,
); );
} else if (type === "indexes") {
result = await cmd.getIndexes(
connectionId,
currentSchema ?? undefined,
);
} else if (type === "constraints") {
result = await cmd.getConstraints(
connectionId,
currentSchema ?? undefined,
);
} else { } else {
result = []; result = [];
} }
@@ -1087,11 +1313,9 @@ export function ObjectExplorerPage({ connectionId }: ObjectExplorerPageProps) {
{!loading && !error && filtered.length === 0 && ( {!loading && !error && filtered.length === 0 && (
<div className="px-3 py-2 text-sm text-text-muted"> <div className="px-3 py-2 text-sm text-text-muted">
{items === null {searchQuery
? `No ${label.toLowerCase()} found` ? `No ${emptyPlural(type)} matching "${searchQuery}"`
: searchQuery : `No ${emptyPlural(type)} found`}
? `No ${label.toLowerCase()} matching "${searchQuery}"`
: `No ${label.toLowerCase()} found in ${currentSchema || "current schema"}`}
</div> </div>
)} )}
+2 -2
View File
@@ -3,7 +3,7 @@ import { ListChecks, Play, Table2, Terminal, X } from "lucide-react";
import { useDbViewerStore } from "../../stores/dbViewerStore"; import { useDbViewerStore } from "../../stores/dbViewerStore";
import { ChangesQueuePanel } from "./ChangesQueuePanel"; import { ChangesQueuePanel } from "./ChangesQueuePanel";
export function TabBar() { export function TabBar({ onCommitted }: { onCommitted?: () => void } = {}) {
const tabs = useDbViewerStore((state) => state.tabs); const tabs = useDbViewerStore((state) => state.tabs);
const activeTabId = useDbViewerStore((state) => state.activeTabId); const activeTabId = useDbViewerStore((state) => state.activeTabId);
const closeTab = useDbViewerStore((state) => state.closeTab); const closeTab = useDbViewerStore((state) => state.closeTab);
@@ -129,7 +129,7 @@ export function TabBar() {
</button> </button>
{changesPanelExpanded && ( {changesPanelExpanded && (
<div className="absolute right-0 top-full mt-1.5 z-30 w-[380px] max-w-[calc(100vw-2rem)] rounded-xl bg-surface border border-border shadow-lg overflow-hidden"> <div className="absolute right-0 top-full mt-1.5 z-30 w-[380px] max-w-[calc(100vw-2rem)] rounded-xl bg-surface border border-border shadow-lg overflow-hidden">
<ChangesQueuePanel /> <ChangesQueuePanel onCommitted={onCommitted} />
</div> </div>
)} )}
</div> </div>
@@ -15,6 +15,8 @@ const columns = [
is_fk: false, is_fk: false,
fk_ref: null, fk_ref: null,
default_value: null, default_value: null,
editable: true,
is_generated: false,
}, },
]; ];
@@ -314,4 +316,19 @@ describe("TableControls", () => {
}); });
expect(onRefresh).toHaveBeenCalledTimes(1); expect(onRefresh).toHaveBeenCalledTimes(1);
}); });
it("hides the Insert Row button for a materialized view", () => {
seed([makeTab()], "tab-1");
renderControls({ isMatview: true });
expect(screen.queryByLabelText(/insert row/i)).toBeNull();
});
it("renders the FilterBuilder inside the filter popover", () => {
seed([makeTab()], "tab-1");
renderControls();
fireEvent.click(screen.getByLabelText(/column filters/i));
expect(
screen.getByText("Drop columns here to add filters"),
).toBeInTheDocument();
});
}); });
+10 -14
View File
@@ -4,7 +4,8 @@ import {
Columns, Check, ChevronLeft, ChevronRight, X, Trash2, Columns, Check, ChevronLeft, ChevronRight, X, Trash2,
ChevronDown, FileJson, FileText, Terminal, ChevronDown, FileJson, FileText, Terminal,
} from "lucide-react"; } from "lucide-react";
import { useDbViewerStore } from "../../stores/dbViewerStore"; import { useDbViewerStore, type FilterRule, type SortRule } from "../../stores/dbViewerStore";
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";
@@ -27,19 +28,6 @@ const EXPORT_FORMATS = [
{ label: "Markdown", ext: "md" }, { label: "Markdown", ext: "md" },
] as const; ] as const;
type FilterRule = {
id: string;
column: string;
operator: "eq" | "neq" | "contains" | "starts" | "ends" | "gt" | "lt" | "null" | "notnull";
value: string;
};
type SortRule = {
id: string;
column: string;
order: "asc" | "desc";
};
// ─── helpers ──────────────────────────────────────────── // ─── helpers ────────────────────────────────────────────
/** /**
@@ -136,6 +124,7 @@ function FilterModal({
<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
@@ -414,6 +403,8 @@ interface TableControlsProps {
selectedRows: unknown[][]; selectedRows: unknown[][];
onClearSelection: () => void; onClearSelection: () => void;
defaultRefreshRate?: number; defaultRefreshRate?: number;
/** Hide data-modifying affordances (e.g. for materialized views). */
isMatview?: boolean;
/** "table" = full table toolbar; "query" = export/refresh/columns + timing */ /** "table" = full table toolbar; "query" = export/refresh/columns + timing */
variant?: "table" | "query"; variant?: "table" | "query";
} }
@@ -435,6 +426,7 @@ export function TableControls({
selectedRows, selectedRows,
onClearSelection, onClearSelection,
defaultRefreshRate = 0, defaultRefreshRate = 0,
isMatview = false,
variant = "table", variant = "table",
}: TableControlsProps) { }: TableControlsProps) {
const isQuery = variant === "query"; const isQuery = variant === "query";
@@ -630,6 +622,8 @@ export function TableControls({
{columnsControl} {columnsControl}
</> </>
) : ( ) : (
<>
{!isMatview && (
<> <>
{/* Insert Row */} {/* Insert Row */}
<Tooltip content="Insert row" side="bottom"> <Tooltip content="Insert row" side="bottom">
@@ -642,6 +636,8 @@ export function TableControls({
<Plus size={14} /> <Plus size={14} />
</button> </button>
</Tooltip> </Tooltip>
</>
)}
{refreshControl} {refreshControl}
@@ -75,7 +75,7 @@ describe("TableOverflowMenu", () => {
it("Export data calls exportData when rows and columns are provided", async () => { it("Export data calls exportData when rows and columns are provided", async () => {
const spy = vi.spyOn(exportData, "exportData"); const spy = vi.spyOn(exportData, "exportData");
const columns = [{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null }]; const columns = [{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false }];
render(<TableOverflowMenu schema="public" table="t" onOpenTab={() => "tab-1"} columns={columns} rows={[[1]]} />); render(<TableOverflowMenu schema="public" table="t" onOpenTab={() => "tab-1"} columns={columns} rows={[[1]]} />);
fireEvent.click(screen.getByLabelText(/table options/i)); fireEvent.click(screen.getByLabelText(/table options/i));
fireEvent.click(screen.getByText(/export data \(csv\)/i)); fireEvent.click(screen.getByText(/export data \(csv\)/i));
@@ -23,6 +23,23 @@ describe("TableTree", () => {
expect(screen.getByText("orders")).toBeInTheDocument(); expect(screen.getByText("orders")).toBeInTheDocument();
}); });
it("shows a distinct icon and label for materialized views", () => {
useDbViewerStore.setState({
schemas: ["public"],
currentSchema: "public",
tables: [
{
name: "mv_products",
schema: "public",
table_type: "MATERIALIZED VIEW" as any,
},
],
});
render(<TableTree />);
expect(screen.getByText("mv_products")).toBeInTheDocument();
expect(screen.getByText("Materialized View")).toBeInTheDocument();
});
it("opens a tab when table is clicked", async () => { it("opens a tab when table is clicked", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
useDbViewerStore.setState({ useDbViewerStore.setState({
+10 -2
View File
@@ -1,5 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { ChevronRight, ChevronDown, Table2, Key, Type } from "lucide-react"; import { ChevronRight, ChevronDown, Table2, Layers, Key, Type } from "lucide-react";
import { useDbViewerStore } from "../../stores/dbViewerStore"; import { useDbViewerStore } from "../../stores/dbViewerStore";
import { useUiStore } from "../../stores/uiStore"; import { useUiStore } from "../../stores/uiStore";
import { TableOverflowMenu } from "./TableOverflowMenu"; import { TableOverflowMenu } from "./TableOverflowMenu";
@@ -70,6 +70,9 @@ export function TableTree({ searchQuery }: { searchQuery?: string }) {
const key = `${table.schema}.${table.name}`; const key = `${table.schema}.${table.name}`;
const isExpanded = expanded.has(key); const isExpanded = expanded.has(key);
const cols = columnCache[key] ?? table.columns ?? []; const cols = columnCache[key] ?? table.columns ?? [];
const isMatView = table.table_type === "MATERIALIZED VIEW";
const TypeIcon = isMatView ? Layers : Table2;
const typeLabel = isMatView ? "Materialized View" : null;
return ( return (
<div key={key}> <div key={key}>
<div <div
@@ -90,10 +93,15 @@ export function TableTree({ searchQuery }: { searchQuery?: string }) {
<ChevronRight size={14} /> <ChevronRight size={14} />
)} )}
</button> </button>
<Table2 size={14} className="text-text-muted" /> <TypeIcon size={14} className="text-text-muted" />
<span className="flex-1 text-left text-sm text-text group-hover:text-accent truncate"> <span className="flex-1 text-left text-sm text-text group-hover:text-accent truncate">
{table.name} {table.name}
</span> </span>
{typeLabel && (
<span className="text-[10px] text-text-subtle shrink-0">
{typeLabel}
</span>
)}
<div onClick={(e) => e.stopPropagation()}> <div onClick={(e) => e.stopPropagation()}>
<TableOverflowMenu <TableOverflowMenu
schema={table.schema} schema={table.schema}
@@ -0,0 +1,49 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { CellContextMenu } from "./CellContextMenu";
describe("CellContextMenu", () => {
const base = {
anchorRect: { top: 0, left: 0, width: 10, height: 10 } as DOMRect,
onClose: vi.fn(),
onCopy: vi.fn(), onCopyJson: vi.fn(), onEdit: vi.fn(), onSetNull: vi.fn(), onOpenFk: vi.fn(),
onViewRow: vi.fn(), onSelectRow: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
it("renders Copy, Edit, Set NULL for an editable scalar cell", () => {
render(<CellContextMenu {...base} editable isJson={false} isFk={false} nullable />);
expect(screen.getByText("Copy")).toBeInTheDocument();
expect(screen.getByText("Edit")).toBeInTheDocument();
expect(screen.getByText("Set NULL")).toBeInTheDocument();
});
it("renders Copy JSON for a json cell and hides Edit when not editable", () => {
render(<CellContextMenu {...base} editable={false} isJson isFk={false} nullable={false} />);
expect(screen.getByText("Copy JSON")).toBeInTheDocument();
expect(screen.queryByText("Edit")).toBeNull();
});
it("renders Open FK reference for an FK cell", () => {
render(<CellContextMenu {...base} editable={false} isJson={false} isFk nullable={false} />);
expect(screen.getByText("Open FK reference")).toBeInTheDocument();
fireEvent.click(screen.getByText("Open FK reference"));
expect(base.onOpenFk).toHaveBeenCalled();
});
it("always renders View Row and Select Row", () => {
render(<CellContextMenu {...base} editable={false} isJson={false} isFk={false} nullable={false} />);
expect(screen.getByText("View Row")).toBeInTheDocument();
expect(screen.getByText("Select Row")).toBeInTheDocument();
});
it("calls onViewRow when View Row is clicked", () => {
render(<CellContextMenu {...base} editable={false} isJson={false} isFk={false} nullable={false} />);
fireEvent.click(screen.getByText("View Row"));
expect(base.onViewRow).toHaveBeenCalledTimes(1);
expect(base.onClose).toHaveBeenCalledTimes(1);
});
it("calls onSelectRow when Select Row is clicked", () => {
render(<CellContextMenu {...base} editable={false} isJson={false} isFk={false} nullable={false} />);
fireEvent.click(screen.getByText("Select Row"));
expect(base.onSelectRow).toHaveBeenCalledTimes(1);
expect(base.onClose).toHaveBeenCalledTimes(1);
});
});
+37
View File
@@ -0,0 +1,37 @@
import { Copy, Braces, Pencil, CircleSlash, Link2, Eye, MousePointerClick } from "lucide-react";
interface Props {
anchorRect: DOMRect;
editable: boolean;
isJson: boolean;
isFk: boolean;
nullable: boolean;
onCopy: () => void; onCopyJson: () => void; onEdit: () => void;
onSetNull: () => void; onOpenFk: () => void; onClose: () => void;
onViewRow: () => void; onSelectRow: () => void;
}
export function CellContextMenu({ anchorRect, editable, isJson, isFk, nullable, onCopy, onCopyJson, onEdit, onSetNull, onOpenFk, onClose, onViewRow, onSelectRow }: Props) {
const items: { label: string; icon: React.ReactNode; onClick: () => void; show: boolean }[] = [
{ label: "Copy", icon: <Copy size={12} />, onClick: () => { onCopy(); onClose(); }, show: true },
{ label: "Copy JSON", icon: <Braces size={12} />, onClick: () => { onCopyJson(); onClose(); }, show: isJson },
{ label: "View Row", icon: <Eye size={12} />, onClick: () => { onViewRow(); onClose(); }, show: true },
{ label: "Select Row", icon: <MousePointerClick size={12} />, onClick: () => { onSelectRow(); onClose(); }, show: true },
{ label: "Edit", icon: <Pencil size={12} />, onClick: () => { onEdit(); onClose(); }, show: editable },
{ label: "Set NULL", icon: <CircleSlash size={12} />, onClick: () => { onSetNull(); onClose(); }, show: editable && nullable },
{ label: "Open FK reference", icon: <Link2 size={12} />, onClick: () => { onOpenFk(); onClose(); }, show: isFk },
];
return (
<div
className="fixed z-50 min-w-[160px] bg-canvas border border-border rounded-md shadow-lg py-1 text-xs"
style={{ top: anchorRect.bottom, left: anchorRect.left }}
onClick={(e) => e.stopPropagation()}
>
{items.filter((i) => i.show).map((i) => (
<button key={i.label} onClick={i.onClick}
className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-surface text-left text-text">
{i.icon} {i.label}
</button>
))}
</div>
);
}
+159
View File
@@ -0,0 +1,159 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { CellEditor } from "./CellEditor";
describe("CellEditor", () => {
it("renders the initial value and commits on Enter", () => {
const onCommit = vi.fn();
const onCancel = vi.fn();
render(<CellEditor initialValue="Alice" dataType="text" onCommit={onCommit} onCancel={onCancel} />);
const input = screen.getByRole("textbox");
expect(input).toHaveValue("Alice");
fireEvent.change(input, { target: { value: "Alicia" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onCommit).toHaveBeenCalledWith("Alicia");
});
it("commits null when the setNull flag is toggled", () => {
const onCommit = vi.fn();
render(<CellEditor initialValue="Alice" dataType="text" onCommit={onCommit} onCancel={vi.fn()} nullable />);
const nullCheckbox = screen.getByLabelText(/set null/i);
fireEvent.click(nullCheckbox);
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" });
expect(onCommit).toHaveBeenCalledWith(null);
});
it("cancels on Escape", () => {
const onCancel = vi.fn();
render(<CellEditor initialValue="Alice" dataType="text" onCommit={vi.fn()} onCancel={onCancel} />);
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Escape" });
expect(onCancel).toHaveBeenCalled();
});
it("uses textarea for large/JSON columns", () => {
render(<CellEditor initialValue="{}" dataType="jsonb" onCommit={vi.fn()} onCancel={vi.fn()} />);
expect(screen.getByRole("textbox").tagName).toBe("TEXTAREA");
});
it("renders a combobox with enum values and commits on change", () => {
const onCommit = vi.fn();
render(
<CellEditor
initialValue="active"
dataType="text"
enumValues={["active", "inactive", "pending"]}
onCommit={onCommit}
onCancel={vi.fn()}
/>
);
const select = screen.getByRole("combobox");
expect(select).toBeInTheDocument();
const labels = screen.getAllByRole("option").map((o) => o.textContent);
expect(labels).toEqual(expect.arrayContaining(["active", "inactive", "pending"]));
fireEvent.change(select, { target: { value: "pending" } });
expect(onCommit).toHaveBeenCalledWith("pending");
});
it("commits null via Set NULL in enum mode", () => {
const onCommit = vi.fn();
render(
<CellEditor
initialValue="active"
dataType="text"
enumValues={["active", "inactive", "pending"]}
onCommit={onCommit}
onCancel={vi.fn()}
nullable
/>
);
fireEvent.click(screen.getByLabelText(/set null/i));
expect(onCommit).toHaveBeenCalledWith(null);
});
it("filters FK options by query and commits the clicked value", () => {
const onCommit = vi.fn();
render(
<CellEditor
initialValue="1"
dataType="integer"
fkOptions={[
{ value: "1", label: "1 — Alice" },
{ value: "2", label: "2 — Bob" },
]}
onCommit={onCommit}
onCancel={vi.fn()}
/>
);
const search = screen.getByLabelText(/search foreign key/i);
fireEvent.change(search, { target: { value: "bo" } });
const buttons = screen.getAllByRole("button");
expect(buttons).toHaveLength(1);
expect(buttons[0]).toHaveTextContent("2 — Bob");
fireEvent.click(buttons[0]);
expect(onCommit).toHaveBeenCalledWith("2");
});
it("shows all FK options when the query is empty", () => {
render(
<CellEditor
initialValue="1"
dataType="integer"
fkOptions={[
{ value: "1", label: "1 — Alice" },
{ value: "2", label: "2 — Bob" },
]}
onCommit={vi.fn()}
onCancel={vi.fn()}
/>
);
expect(screen.getAllByRole("button")).toHaveLength(2);
});
it("renders text columns as a single-line scrolling textarea", () => {
render(<CellEditor initialValue="long text" dataType="text" onCommit={vi.fn()} onCancel={vi.fn()} />);
const input = screen.getByRole("textbox");
expect(input.tagName).toBe("TEXTAREA");
expect(input.className).toContain("h-6");
});
it("renders the FK placeholder and a No matches empty state", () => {
const onCommit = vi.fn();
render(<CellEditor initialValue="" dataType="integer"
fkOptions={[{ value: "1", label: "1 — Alice" }]} fkPlaceholder="Search users…"
onCommit={onCommit} onCancel={vi.fn()} />);
const search = screen.getByLabelText(/search foreign key/i);
expect(search).toHaveAttribute("placeholder", "Search users…");
fireEvent.change(search, { target: { value: "zzz" } });
expect(screen.getByText("No matches")).toBeInTheDocument();
});
it("renders the FK option list as a fixed-position overlay so it is never clipped", () => {
const onCommit = vi.fn();
render(<CellEditor initialValue="" dataType="integer"
fkOptions={[{ value: "1", label: "1 — Alice" }, { value: "2", label: "2 — Bob" }]}
onCommit={onCommit} onCancel={vi.fn()} />);
const list = screen.getByTestId("fk-options");
expect(list.style.position).toBe("fixed");
expect(document.body.contains(list)).toBe(true);
expect(list).toHaveTextContent("1 — Alice");
expect(list).toHaveTextContent("2 — Bob");
});
it("renders FK options as one-row values only (FK-reference style, cap 4, fixed width)", () => {
const onCommit = vi.fn();
const cells = Array.from({ length: 6 }, (_, i) => ({
name: `col${i}`,
value: `v${i}`,
}));
render(<CellEditor initialValue="" dataType="integer"
fkOptions={[{ value: "1", label: "1", cells }]}
onCommit={onCommit} onCancel={vi.fn()} />);
const list = screen.getByTestId("fk-options");
// first 3 values shown, column names NOT shown, 4th+ capped
for (let i = 0; i < 4; i++) {
expect(list).toHaveTextContent(`v${i}`);
}
expect(list).not.toHaveTextContent("col0");
expect(list).not.toHaveTextContent("v4");
// fixed 360px width, FK-viewer surface styling
expect(list.style.width).toBe("360px");
expect(list.className).toContain("bg-surface");
expect(list.className).toContain("rounded-lg");
expect(list.className).toContain("shadow-xl");
// clicking the row commits the value
fireEvent.click(screen.getByRole("button"));
expect(onCommit).toHaveBeenCalledWith("1");
});
});
+255
View File
@@ -0,0 +1,255 @@
import { useState, useRef, useEffect, useLayoutEffect } from "react";
import { createPortal } from "react-dom";
export interface FkOption {
value: string; // the referenced column's value (what gets committed)
label: string; // fallback display text (e.g. "42 — Alice")
/** Referenced row cells shown in one row (≤5 columns), FK-reference style. */
cells?: { name: string; value: string }[];
}
const MAX_FK_CELLS = 4;
const FK_DROPDOWN_WIDTH = 360;
interface CellEditorProps {
initialValue: string;
dataType: string;
nullable?: boolean;
enumValues?: string[]; // when present → render <select> of these values
fkOptions?: FkOption[]; // when present → render searchable dropdown
fkPlaceholder?: string; // placeholder for the FK search input
onCommit: (value: string | null) => void;
onCancel: () => void;
}
const inputClass =
"w-full px-1 py-0.5 text-xs bg-surface border border-border rounded font-mono";
export function CellEditor({
initialValue,
dataType,
nullable,
enumValues,
fkOptions,
fkPlaceholder,
onCommit,
onCancel,
}: CellEditorProps) {
const [value, setValue] = useState(initialValue);
const [setNull, setSetNull] = useState(initialValue === "" && nullable);
const [query, setQuery] = useState("");
const ref = useRef<HTMLTextAreaElement | HTMLInputElement>(null);
const enumRef = useRef<HTMLSelectElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const [fkDropdownPos, setFkDropdownPos] = useState<{
top: number;
left: number;
width: number;
} | null>(null);
useLayoutEffect(() => {
if (fkOptions && fkOptions.length > 0 && searchRef.current) {
const r = searchRef.current.getBoundingClientRect();
let left = r.left;
if (left + FK_DROPDOWN_WIDTH > window.innerWidth - 16) {
left = Math.max(16, window.innerWidth - FK_DROPDOWN_WIDTH - 16);
}
setFkDropdownPos({
top: r.bottom + 4,
left,
width: FK_DROPDOWN_WIDTH,
});
}
}, [fkOptions]);
useEffect(() => {
if (enumValues && enumValues.length > 0) {
enumRef.current?.focus();
} else if (fkOptions && fkOptions.length > 0) {
searchRef.current?.focus();
searchRef.current?.select();
} else {
ref.current?.focus();
ref.current?.select();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const large = ["json", "jsonb", "text"].some((t) =>
dataType.toLowerCase().includes(t)
);
const Tag = large ? "textarea" : "input";
const commit = () => onCommit(setNull ? null : value);
const handleSetNull = (checked: boolean) => {
setSetNull(checked);
if (checked) onCommit(null);
};
// Priority: enum > FK > default input/textarea
if (enumValues && enumValues.length > 0) {
return (
<div className="flex flex-col gap-1 p-1 bg-canvas border border-accent rounded">
<select
ref={enumRef}
className={inputClass}
value={initialValue}
onChange={(e) => onCommit(setNull ? null : e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
}}
>
<option value="">{nullable ? "NULL" : "—"}</option>
{enumValues.map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
{nullable && (
<label className="flex items-center gap-1 text-[10px] text-text-muted">
<input
type="checkbox"
checked={setNull}
onChange={(e) => handleSetNull(e.target.checked)}
/>
Set NULL
</label>
)}
</div>
);
}
if (fkOptions && fkOptions.length > 0) {
const q = query.trim().toLowerCase();
const fkSearchText = (o: FkOption) =>
[
o.label,
...(o.cells ?? []).map((c) => `${c.name}:${c.value}`),
]
.join(" ")
.toLowerCase();
const filtered =
q === ""
? fkOptions
: fkOptions.filter((o) => fkSearchText(o).includes(q));
return (
<div className="flex flex-col gap-1 p-1 bg-canvas border border-accent rounded">
<input
ref={searchRef}
aria-label="Search foreign key options"
placeholder={fkPlaceholder ?? "Search…"}
className={inputClass}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
if (filtered.length > 0) onCommit(filtered[0].value);
else onCommit(query);
} else if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
}}
/>
{fkDropdownPos &&
createPortal(
<div
data-testid="fk-options"
style={{
position: "fixed",
top: fkDropdownPos.top,
left: fkDropdownPos.left,
width: fkDropdownPos.width,
minWidth: fkDropdownPos.width,
zIndex: 50,
}}
className="max-h-28 overflow-y-auto bg-surface border border-border rounded-lg shadow-xl"
>
{filtered.length === 0 && (
<div className="px-2 py-1 text-xs text-text-muted">No matches</div>
)}
{filtered.map((o) => (
<button
key={o.value}
type="button"
className="block w-full px-3 py-1.5 hover:bg-surface-raised text-xs text-left"
onClick={() => onCommit(o.value)}
>
{o.cells && o.cells.length > 0 ? (
<span className="flex items-center gap-0 min-w-0">
{o.cells.slice(0, MAX_FK_CELLS).map((c, ci) => (
<span key={ci} className="flex items-center min-w-0">
{ci > 0 && (
<span className="mx-1.5 h-3 w-px bg-border shrink-0" />
)}
{c.value === "" ? (
<span className="italic text-text-muted">NULL</span>
) : (
<span className="truncate text-text">{c.value}</span>
)}
</span>
))}
</span>
) : (
o.label
)}
</button>
))}
</div>,
document.body,
)}
{nullable && (
<label className="flex items-center gap-1 text-[10px] text-text-muted">
<input
type="checkbox"
checked={setNull}
onChange={(e) => handleSetNull(e.target.checked)}
/>
Set NULL
</label>
)}
</div>
);
}
const cls = large ? `${inputClass} h-6 resize-none overflow-y-auto leading-none` : inputClass;
return (
<div className="flex flex-col gap-1 p-1 bg-canvas border border-accent rounded">
<Tag
ref={ref as any}
className={cls}
value={value}
onChange={(e) => {
setValue(e.target.value);
setSetNull(false);
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
commit();
} else if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
}}
/>
{nullable && (
<label className="flex items-center gap-1 text-[10px] text-text-muted">
<input
type="checkbox"
checked={setNull}
onChange={(e) => handleSetNull(e.target.checked)}
/>
Set NULL
</label>
)}
</div>
);
}
@@ -0,0 +1,25 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { RowDetailDrawer } from "./RowDetailDrawer";
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: "data", data_type: "jsonb", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
];
describe("RowDetailDrawer", () => {
it("renders every column value including hidden ones", () => {
render(<RowDetailDrawer columns={cols} row={[1, { a: 2 }]} onClose={vi.fn()} onCopy={vi.fn()} />);
expect(screen.getByText("id")).toBeInTheDocument();
expect(screen.getByText("data")).toBeInTheDocument();
expect(screen.getByText(/"a": 2/)).toBeInTheDocument();
});
it("copy button calls onCopy with the raw value", () => {
const onCopy = vi.fn();
render(<RowDetailDrawer columns={cols} row={[1, { a: 2 }]} onClose={vi.fn()} onCopy={onCopy} />);
const copyBtns = screen.getAllByRole("button", { name: /copy/i });
fireEvent.click(copyBtns[1]); // copy the 'data' value
expect(onCopy).toHaveBeenCalledWith(JSON.stringify({ a: 2 }, null, 2));
});
});
+40
View File
@@ -0,0 +1,40 @@
import { X, Copy } from "lucide-react";
import type { ColumnInfo } from "../../lib/types";
interface Props {
columns: ColumnInfo[];
row: unknown[];
onClose: () => void;
onCopy: (value: string) => void;
}
function fmt(v: unknown): string {
if (v === null || v === undefined) return "NULL";
if (typeof v === "object") return JSON.stringify(v, null, 2);
return String(v);
}
export function RowDetailDrawer({ columns, row, onClose, onCopy }: Props) {
return (
<div className="fixed top-0 right-0 h-full w-96 bg-canvas border-l border-border z-40 flex flex-col">
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<span className="text-sm font-semibold text-text">Row detail</span>
<button onClick={onClose} aria-label="Close" className="text-text-muted hover:text-text"><X size={16} /></button>
</div>
<div className="flex-1 overflow-auto p-3 space-y-2">
{columns.map((c, i) => {
const v = row[i];
const isJson = c.data_type === "json" || c.data_type === "jsonb";
const text = fmt(v);
return (
<div key={c.name} className="border border-border rounded p-2">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-text">{c.name}</span>
<button onClick={() => onCopy(text)} aria-label={`Copy ${c.name}`} className="text-text-muted hover:text-text"><Copy size={12} /></button>
</div>
<pre className={`text-xs mt-1 whitespace-pre-wrap break-all ${isJson ? "font-mono text-accent/80" : "text-text-muted"}`}>{text}</pre>
</div>
);
})}
</div>
</div>
);
}
+340 -22
View File
@@ -4,8 +4,8 @@ import { VirtualDataGrid } from "./VirtualDataGrid";
import type { ColumnInfo } from "../../lib/types"; import type { ColumnInfo } from "../../lib/types";
const mockColumns: ColumnInfo[] = [ const mockColumns: ColumnInfo[] = [
{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null }, { 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 }, { 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 },
]; ];
const mockRows: unknown[][] = [ const mockRows: unknown[][] = [
@@ -36,6 +36,78 @@ describe("VirtualDataGrid", () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it("focuses a cell on click and opens the editor on Enter for an editable cell", () => {
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
render(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
dbType="postgresql" tabType="table" />);
const nameCell = screen.getAllByText("Alice")[0];
fireEvent.click(nameCell);
fireEvent.keyDown(nameCell, { key: "Enter" });
expect(screen.getByRole("textbox")).toBeInTheDocument();
});
it("shows staged values passed from the parent + a pending dot", () => {
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
render(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
dbType="postgresql" tabType="table" stagedValues={{ "0:name": "Alicia" }} pendingKeys={{ "0:name": true }} />);
expect(screen.getByText("Alicia")).toBeInTheDocument();
expect(screen.getByTestId("pending-edit-dot")).toBeInTheDocument();
});
it("pending dot requires pendingKeys even when a staged value exists (committed → no dot)", () => {
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
render(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
dbType="postgresql" tabType="table" stagedValues={{ "0:name": "Alicia" }} pendingKeys={{}} />);
expect(screen.getByText("Alicia")).toBeInTheDocument();
expect(screen.queryByTestId("pending-edit-dot")).toBeNull();
});
it("clears staged values when the stagedValues prop empties (Clear All)", () => {
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
const { rerender } = render(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
dbType="postgresql" tabType="table" stagedValues={{ "0:name": "Alicia" }} />);
expect(screen.getByText("Alicia")).toBeInTheDocument();
rerender(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
dbType="postgresql" tabType="table" stagedValues={{}} />);
expect(screen.getAllByText("Alice")[0]).toBeInTheDocument();
expect(screen.queryByText("Alicia")).toBeNull();
});
it("Ctrl+C copies the focused cell value to the clipboard", async () => {
const writeText = vi.fn();
Object.assign(navigator, { clipboard: { writeText } });
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
render(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
dbType="postgresql" tabType="table" />);
const cell = screen.getAllByText("Alice")[0];
fireEvent.click(cell);
fireEvent.keyDown(cell, { key: "c", ctrlKey: true });
expect(writeText).toHaveBeenCalledWith("Alice");
});
it("does not open an editor for a PK cell", () => {
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
render(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
dbType="postgresql" tabType="table" />);
const idCell = screen.getByText("1");
fireEvent.click(idCell);
fireEvent.keyDown(idCell, { key: "Enter" });
expect(screen.queryByRole("textbox")).toBeNull();
});
it("renders all rows when row count is small", () => { it("renders all rows when row count is small", () => {
mockGetTotalSize.mockReturnValue(mockRows.length * 36); mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue( mockGetVirtualItems.mockReturnValue(
@@ -51,12 +123,15 @@ describe("VirtualDataGrid", () => {
<VirtualDataGrid <VirtualDataGrid
connectionId="conn-1" connectionId="conn-1"
schema="public" schema="public"
table="users"
rows={mockRows} rows={mockRows}
columns={mockColumns} columns={mockColumns}
hiddenColumns={new Set()} hiddenColumns={new Set()}
selectedRows={new Set()} selectedRows={new Set()}
onToggleRow={() => {}} onToggleRow={() => {}}
onToggleAll={() => {}} onToggleAll={() => {}}
dbType="postgresql"
tabType="table"
/>, />,
); );
@@ -79,12 +154,15 @@ describe("VirtualDataGrid", () => {
<VirtualDataGrid <VirtualDataGrid
connectionId="conn-1" connectionId="conn-1"
schema="public" schema="public"
table="users"
rows={mockRows} rows={mockRows}
columns={mockColumns} columns={mockColumns}
hiddenColumns={new Set()} hiddenColumns={new Set()}
selectedRows={new Set()} selectedRows={new Set()}
onToggleRow={() => {}} onToggleRow={() => {}}
onToggleAll={() => {}} onToggleAll={() => {}}
dbType="postgresql"
tabType="table"
/>, />,
); );
@@ -109,12 +187,15 @@ describe("VirtualDataGrid", () => {
<VirtualDataGrid <VirtualDataGrid
connectionId="conn-1" connectionId="conn-1"
schema="public" schema="public"
table="users"
rows={rows} rows={rows}
columns={mockColumns} columns={mockColumns}
hiddenColumns={new Set()} hiddenColumns={new Set()}
selectedRows={new Set()} selectedRows={new Set()}
onToggleRow={() => {}} onToggleRow={() => {}}
onToggleAll={() => {}} onToggleAll={() => {}}
dbType="postgresql"
tabType="table"
/>, />,
); );
@@ -130,12 +211,15 @@ describe("VirtualDataGrid", () => {
<VirtualDataGrid <VirtualDataGrid
connectionId="conn-1" connectionId="conn-1"
schema="public" schema="public"
table="users"
rows={[]} rows={[]}
columns={mockColumns} columns={mockColumns}
hiddenColumns={new Set()} hiddenColumns={new Set()}
selectedRows={new Set()} selectedRows={new Set()}
onToggleRow={() => {}} onToggleRow={() => {}}
onToggleAll={() => {}} onToggleAll={() => {}}
dbType="postgresql"
tabType="table"
/>, />,
); );
@@ -147,41 +231,43 @@ describe("VirtualDataGrid", () => {
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 })));
render(<VirtualDataGrid connectionId="conn-1" schema="public" rows={mockRows} columns={mockColumns} render(<VirtualDataGrid connectionId="conn-1" schema="public" table="users" rows={mockRows} columns={mockColumns}
hiddenColumns={new Set()} selectedRows={new Set()} hiddenColumns={new Set()} selectedRows={new Set()}
onToggleRow={(i) => { toggled = i; }} onToggleAll={() => {}} />); onToggleRow={(i) => { toggled = i; }} onToggleAll={() => {}}
dbType="postgresql" tabType="table" />);
const checkboxes = screen.getAllByRole("checkbox"); const checkboxes = screen.getAllByRole("checkbox");
fireEvent.click(checkboxes[1]); // first row checkbox fireEvent.click(checkboxes[1]); // first row checkbox
expect(toggled).toBe(0); expect(toggled).toBe(0);
}); });
it("renders FK cells with clickable underline styling", () => { it("renders FK cells with an FK reference icon button", () => {
const fkCols: ColumnInfo[] = [ const fkCols: ColumnInfo[] = [
{ name: "user_id", data_type: "integer", is_nullable: false, is_pk: false, is_fk: true, fk_ref: ["users", "id"], default_value: null }, { name: "user_id", data_type: "integer", is_nullable: false, is_pk: false, is_fk: true, fk_ref: ["users", "id"], default_value: null, editable: true, is_generated: false },
]; ];
mockGetTotalSize.mockReturnValue(36); mockGetTotalSize.mockReturnValue(36);
mockGetVirtualItems.mockReturnValue([{ key: 0, index: 0, start: 0, size: 36 }]); mockGetVirtualItems.mockReturnValue([{ key: 0, index: 0, start: 0, size: 36 }]);
render(<VirtualDataGrid connectionId="conn-1" schema="public" rows={[[42]]} columns={fkCols} render(<VirtualDataGrid connectionId="conn-1" schema="public" table="orders" rows={[[42]]} columns={fkCols}
hiddenColumns={new Set()} selectedRows={new Set()} hiddenColumns={new Set()} selectedRows={new Set()}
onToggleRow={() => {}} onToggleAll={() => {}} />); onToggleRow={() => {}} onToggleAll={() => {}}
dbType="postgresql" tabType="table" />);
const fkCell = screen.getByText("42"); expect(screen.getByText("42")).toBeInTheDocument();
expect(fkCell.className).toContain("cursor-pointer"); expect(screen.getByLabelText("Open FK reference")).toBeInTheDocument();
expect(fkCell.className).toContain("underline");
}); });
it("renders JSON cells with preview label", () => { it("renders JSON cells with preview label", () => {
const jsonCols: ColumnInfo[] = [ const jsonCols: ColumnInfo[] = [
{ name: "metadata", data_type: "jsonb", is_nullable: false, is_pk: false, is_fk: false, fk_ref: null, default_value: null }, { name: "metadata", data_type: "jsonb", is_nullable: false, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
]; ];
mockGetTotalSize.mockReturnValue(36); mockGetTotalSize.mockReturnValue(36);
mockGetVirtualItems.mockReturnValue([{ key: 0, index: 0, start: 0, size: 36 }]); mockGetVirtualItems.mockReturnValue([{ key: 0, index: 0, start: 0, size: 36 }]);
render(<VirtualDataGrid connectionId="conn-1" schema="public" rows={[[JSON.stringify({ key: "val", count: 3 })]]} columns={jsonCols} render(<VirtualDataGrid connectionId="conn-1" schema="public" table="users" rows={[[JSON.stringify({ key: "val", count: 3 })]]} columns={jsonCols}
hiddenColumns={new Set()} selectedRows={new Set()} hiddenColumns={new Set()} selectedRows={new Set()}
onToggleRow={() => {}} onToggleAll={() => {}} />); onToggleRow={() => {}} onToggleAll={() => {}}
dbType="postgresql" tabType="table" />);
expect(screen.getByText(/2 keys/)).toBeInTheDocument(); expect(screen.getByText(/2 keys/)).toBeInTheDocument();
}); });
@@ -194,12 +280,15 @@ describe("VirtualDataGrid", () => {
<VirtualDataGrid <VirtualDataGrid
connectionId="conn-1" connectionId="conn-1"
schema="public" schema="public"
table="users"
rows={[]} rows={[]}
columns={mockColumns} columns={mockColumns}
hiddenColumns={new Set()} hiddenColumns={new Set()}
selectedRows={new Set()} selectedRows={new Set()}
onToggleRow={() => {}} onToggleRow={() => {}}
onToggleAll={() => {}} onToggleAll={() => {}}
dbType="postgresql"
tabType="table"
/>, />,
); );
@@ -225,12 +314,15 @@ describe("VirtualDataGrid", () => {
<VirtualDataGrid <VirtualDataGrid
connectionId="conn-1" connectionId="conn-1"
schema="public" schema="public"
table="users"
rows={[]} rows={[]}
columns={[]} columns={[]}
hiddenColumns={new Set()} hiddenColumns={new Set()}
selectedRows={new Set()} selectedRows={new Set()}
onToggleRow={() => {}} onToggleRow={() => {}}
onToggleAll={() => {}} onToggleAll={() => {}}
dbType="postgresql"
tabType="table"
/>, />,
); );
@@ -244,9 +336,10 @@ describe("VirtualDataGrid", () => {
mockGetTotalSize.mockReturnValue(0); mockGetTotalSize.mockReturnValue(0);
mockGetVirtualItems.mockReturnValue([]); mockGetVirtualItems.mockReturnValue([]);
render(<VirtualDataGrid connectionId="conn-1" schema="public" rows={[]} columns={mockColumns} render(<VirtualDataGrid connectionId="conn-1" schema="public" table="users" rows={[]} columns={mockColumns}
hiddenColumns={new Set()} selectedRows={new Set()} hiddenColumns={new Set()} selectedRows={new Set()}
onToggleRow={() => {}} onToggleAll={() => {}} />); onToggleRow={() => {}} onToggleAll={() => {}}
dbType="postgresql" tabType="table" />);
const handles = document.querySelectorAll('[class*="cursor-col-resize"]'); const handles = document.querySelectorAll('[class*="cursor-col-resize"]');
expect(handles.length).toBe(2); // one per visible column expect(handles.length).toBe(2); // one per visible column
@@ -259,9 +352,10 @@ describe("VirtualDataGrid", () => {
Array.from({ length: 20 }, (_, i) => ({ key: i, index: i, start: i * 36, size: 36 })) Array.from({ length: 20 }, (_, i) => ({ key: i, index: i, start: i * 36, size: 36 }))
); );
render( render(
<VirtualDataGrid connectionId="conn-1" schema="public" rows={bigRows} columns={mockColumns} <VirtualDataGrid connectionId="conn-1" schema="public" table="users" rows={bigRows} columns={mockColumns}
hiddenColumns={new Set()} selectedRows={new Set()} hiddenColumns={new Set()} selectedRows={new Set()}
onToggleRow={() => {}} onToggleAll={() => {}} />, onToggleRow={() => {}} onToggleAll={() => {}}
dbType="postgresql" tabType="table" />,
); );
const checkboxes = screen.getAllByRole("checkbox"); const checkboxes = screen.getAllByRole("checkbox");
expect(checkboxes.length).toBeLessThan(50); // virtualized: only visible rows + select all expect(checkboxes.length).toBeLessThan(50); // virtualized: only visible rows + select all
@@ -274,9 +368,10 @@ describe("VirtualDataGrid", () => {
mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })) mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 }))
); );
render( render(
<VirtualDataGrid connectionId="conn-1" schema="public" rows={mockRows} columns={mockColumns} <VirtualDataGrid connectionId="conn-1" schema="public" table="users" rows={mockRows} columns={mockColumns}
hiddenColumns={new Set()} selectedRows={allSelected} hiddenColumns={new Set()} selectedRows={allSelected}
onToggleRow={() => {}} onToggleAll={() => {}} />, onToggleRow={() => {}} onToggleAll={() => {}}
dbType="postgresql" tabType="table" />,
); );
const selectAll = screen.getAllByRole("checkbox")[0] as HTMLInputElement; const selectAll = screen.getAllByRole("checkbox")[0] as HTMLInputElement;
expect(selectAll.checked).toBe(true); expect(selectAll.checked).toBe(true);
@@ -289,11 +384,234 @@ describe("VirtualDataGrid", () => {
mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })) mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 }))
); );
render( render(
<VirtualDataGrid connectionId="conn-1" schema="public" rows={mockRows} columns={mockColumns} <VirtualDataGrid connectionId="conn-1" schema="public" table="users" rows={mockRows} columns={mockColumns}
hiddenColumns={hidden} selectedRows={new Set()} hiddenColumns={hidden} selectedRows={new Set()}
onToggleRow={() => {}} onToggleAll={() => {}} />, onToggleRow={() => {}} onToggleAll={() => {}}
dbType="postgresql" tabType="table" />,
); );
expect(screen.queryByText("name")).not.toBeInTheDocument(); expect(screen.queryByText("name")).not.toBeInTheDocument();
expect(screen.getByText("id")).toBeInTheDocument(); expect(screen.getByText("id")).toBeInTheDocument();
}); });
it("renders a pending-edit dot on the pending cell", () => {
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(
mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })),
);
render(
<VirtualDataGrid
connectionId="conn-1"
schema="public"
table="users"
rows={mockRows}
columns={mockColumns}
hiddenColumns={new Set()}
selectedRows={new Set()}
onToggleRow={() => {}}
onToggleAll={() => {}}
dbType="postgresql"
tabType="table"
pendingCell={{ row: 0, col: 1 }}
/>,
);
expect(screen.getByTestId("pending-edit-dot")).toBeInTheDocument();
});
it("does not render a pending-edit dot without pendingCell", () => {
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(
mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })),
);
render(
<VirtualDataGrid
connectionId="conn-1"
schema="public"
table="users"
rows={mockRows}
columns={mockColumns}
hiddenColumns={new Set()}
selectedRows={new Set()}
onToggleRow={() => {}}
onToggleAll={() => {}}
dbType="postgresql"
tabType="table"
/>,
);
expect(screen.queryByTestId("pending-edit-dot")).toBeNull();
});
// ── GRID-A: context menu + editing behavior ─────────────────────────
it("opens the context menu on right-click and View Row calls onOpenRowDetail", () => {
let opened = -1;
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
render(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
dbType="postgresql" tabType="table" onOpenRowDetail={(i) => { opened = i; }} />);
fireEvent.contextMenu(screen.getByText("Alice"));
expect(screen.getByText("View Row")).toBeInTheDocument();
fireEvent.click(screen.getByText("View Row"));
expect(opened).toBe(0);
});
it("context menu Select Row calls onToggleRow with the row index", () => {
let toggled = -1;
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
render(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={(i) => { toggled = i; }} onToggleAll={vi.fn()}
dbType="postgresql" tabType="table" />);
fireEvent.contextMenu(screen.getByText("Bob"));
fireEvent.click(screen.getByText("Select Row"));
expect(toggled).toBe(1);
});
it("closes the context menu when clicking the backdrop", () => {
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
render(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
dbType="postgresql" tabType="table" />);
fireEvent.contextMenu(screen.getByText("Alice"));
expect(screen.getByText("Copy")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("ctx-backdrop"));
expect(screen.queryByText("Copy")).toBeNull();
});
it("cancels in-cell editing with Escape even when the editor input is unfocused", () => {
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
render(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
dbType="postgresql" tabType="table" />);
const cell = screen.getAllByText("Alice")[0];
fireEvent.click(cell);
fireEvent.keyDown(cell, { key: "Enter" });
expect(screen.getByRole("textbox")).toBeInTheDocument();
// Editor input is not the event target — the document-level listener must cancel.
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByRole("textbox")).toBeNull();
});
it("opens the FK preview popover from the context menu", () => {
const fkCols: ColumnInfo[] = [
{ name: "user_id", data_type: "integer", is_nullable: false, is_pk: false, is_fk: true, fk_ref: ["users", "id"], default_value: null, editable: true, is_generated: false },
];
mockGetTotalSize.mockReturnValue(36);
mockGetVirtualItems.mockReturnValue([{ key: 0, index: 0, start: 0, size: 36 }]);
render(<VirtualDataGrid connectionId="conn-1" schema="public" table="orders" rows={[[42]]} columns={fkCols}
hiddenColumns={new Set()} selectedRows={new Set()}
onToggleRow={() => {}} onToggleAll={() => {}}
dbType="postgresql" tabType="table" />);
fireEvent.contextMenu(screen.getByText("42"));
fireEvent.click(screen.getByText("Open FK reference"));
// Popover header renders the referenced table synchronously.
expect(screen.getByText("public.users")).toBeInTheDocument();
});
it("clicking an FK cell does NOT open the FK preview (only the icon does)", () => {
const fkCols: ColumnInfo[] = [
{ name: "user_id", data_type: "integer", is_nullable: false, is_pk: false, is_fk: true, fk_ref: ["users", "id"], default_value: null, editable: true, is_generated: false },
];
mockGetTotalSize.mockReturnValue(36);
mockGetVirtualItems.mockReturnValue([{ key: 0, index: 0, start: 0, size: 36 }]);
render(<VirtualDataGrid connectionId="conn-1" schema="public" table="orders" rows={[[42]]} columns={fkCols}
hiddenColumns={new Set()} selectedRows={new Set()}
onToggleRow={() => {}} onToggleAll={() => {}}
dbType="postgresql" tabType="table" />);
fireEvent.click(screen.getByText("42"));
expect(screen.queryByText("public.users")).toBeNull();
// The FK icon button opens the preview popover.
fireEvent.click(screen.getByLabelText("Open FK reference"));
expect(screen.getByText("public.users")).toBeInTheDocument();
});
// ── GRID-C: enum + FK options fed into CellEditor ────────────────
it("renders an enum <select> with the column's labels when editing", () => {
const enumCols: ColumnInfo[] = [
{ name: "status", data_type: "user_role", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
];
mockGetTotalSize.mockReturnValue(36);
mockGetVirtualItems.mockReturnValue([{ key: 0, index: 0, start: 0, size: 36 }]);
render(
<VirtualDataGrid
connectionId="c1"
schema="public"
table="users"
rows={[["active"]]}
columns={enumCols}
hiddenColumns={new Set()}
selectedRows={new Set()}
onToggleRow={vi.fn()}
onToggleAll={vi.fn()}
dbType="postgresql"
tabType="table"
enumValues={{ status: ["active", "inactive"] }}
/>,
);
const cell = screen.getByText("active");
fireEvent.click(cell);
fireEvent.keyDown(cell, { key: "Enter" });
expect(screen.getByRole("combobox")).toBeInTheDocument();
expect(screen.getByRole("option", { name: "active" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "inactive" })).toBeInTheDocument();
});
it("renders a searchable FK dropdown with the referenced rows when editing", () => {
const fkCols: ColumnInfo[] = [
{ name: "user_id", data_type: "integer", is_nullable: false, is_pk: false, is_fk: true, fk_ref: ["users", "id"], default_value: null, editable: true, is_generated: false },
];
mockGetTotalSize.mockReturnValue(36);
mockGetVirtualItems.mockReturnValue([{ key: 0, index: 0, start: 0, size: 36 }]);
render(
<VirtualDataGrid
connectionId="c1"
schema="public"
table="orders"
rows={[[42]]}
columns={fkCols}
hiddenColumns={new Set()}
selectedRows={new Set()}
onToggleRow={vi.fn()}
onToggleAll={vi.fn()}
dbType="postgresql"
tabType="table"
fkOptions={{
user_id: [
{ value: "1", label: "1 — Alice" },
{ value: "2", label: "2 — Bob" },
],
}}
/>,
);
const cell = screen.getByText("42");
fireEvent.click(cell);
fireEvent.keyDown(cell, { key: "Enter" });
expect(screen.getByLabelText(/search foreign key/i)).toBeInTheDocument();
expect(screen.getByText("1 — Alice")).toBeInTheDocument();
expect(screen.getByText("2 — Bob")).toBeInTheDocument();
});
}); });
+351 -28
View File
@@ -1,20 +1,50 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual"; import { useVirtualizer } from "@tanstack/react-virtual";
import { Key, Braces } from "lucide-react"; import { Key, Braces, ArrowUpRight } from "lucide-react";
import type { ColumnInfo } from "../../lib/types"; import type { ColumnInfo } from "../../lib/types";
import { abbreviateType } from "../../lib/utils"; import { abbreviateType } from "../../lib/utils";
import { FkPreviewPopover } from "../db-viewer/FkPreviewPopover"; import { FkPreviewPopover } from "../db-viewer/FkPreviewPopover";
import { JsonCellPopover, jsonPreview } from "../db-viewer/JsonCellPopover"; import { JsonCellPopover, jsonPreview } from "../db-viewer/JsonCellPopover";
import { CellEditor, type FkOption } from "./CellEditor";
import { CellContextMenu } from "./CellContextMenu";
import { cellToUpdateChange, isCellEditable } from "./gridEditability";
import { nextCell, type CellPos } from "./keyboardNav";
interface VirtualDataGridProps { interface VirtualDataGridProps {
connectionId: string; connectionId: string;
schema: string; schema: string;
table?: string;
rows: unknown[][]; rows: unknown[][];
columns: ColumnInfo[]; columns: ColumnInfo[];
hiddenColumns: Set<string>; hiddenColumns: Set<string>;
selectedRows: Set<number>; selectedRows: Set<number>;
onToggleRow: (rowIndex: number) => void; onToggleRow: (rowIndex: number) => void;
onToggleAll: () => void; onToggleAll: () => void;
dbType?: string;
tabType?: "table" | "query";
onStageEdit?: (payload: {
type: "update";
schema: string;
table: string;
primaryKey: Record<string, unknown>;
oldData: Record<string, unknown>;
newData: Record<string, unknown>;
}) => void;
onOpenRowDetail?: (rowIndex: number) => void;
getLocator?: (row: unknown[]) => Record<string, unknown>;
readOnly?: boolean;
/** When set, renders a pending-edit indicator on the staged cell at (row, col). */
pendingCell?: { row: number; col: number } | null;
/** Enum labels keyed by column NAME → renders a <select> in the CellEditor. */
enumValues?: Record<string, string[]>;
/** Foreign-key reference rows keyed by column NAME → renders a searchable dropdown in the CellEditor. */
fkOptions?: Record<string, FkOption[]>;
/** Placeholder text for the FK search input, keyed by column NAME. */
fkPlaceholders?: Record<string, string>;
/** Optimistic staged cell values keyed `${rowIndex}:${colName}` → value (null = NULL), from the changes queue. */
stagedValues?: Record<string, string | null>;
/** Keys of cells with a PENDING (not yet committed) update → drives the amber dot. */
pendingKeys?: Record<string, boolean>;
} }
const ROW_HEIGHT = 36; const ROW_HEIGHT = 36;
@@ -25,12 +55,25 @@ const MAX_COL_WIDTH = 800;
export function VirtualDataGrid({ export function VirtualDataGrid({
connectionId, connectionId,
schema, schema,
table = "",
rows, rows,
columns, columns,
hiddenColumns, hiddenColumns,
selectedRows, selectedRows,
onToggleRow, onToggleRow,
onToggleAll, onToggleAll,
dbType = "postgresql",
tabType = "table",
onStageEdit,
onOpenRowDetail,
getLocator,
readOnly = false,
pendingCell = null,
enumValues,
fkOptions,
fkPlaceholders,
stagedValues,
pendingKeys,
}: VirtualDataGridProps) { }: VirtualDataGridProps) {
const parentRef = useRef<HTMLDivElement>(null); const parentRef = useRef<HTMLDivElement>(null);
@@ -53,6 +96,45 @@ export function VirtualDataGrid({
overscan: 5, overscan: 5,
}); });
// ── focus / editing / context menu / row detail state ──
const [activeCell, setActiveCell] = useState<CellPos | null>(null);
const [editingCell, setEditingCell] = useState<CellPos | null>(null);
// Optimistic staged cell values come from the parent via `stagedValues`
// (derived from the changes queue), so clearing the queue clears them.
const [pendingCellKey, setPendingCellKey] = useState<string | null>(null);
useEffect(() => {
setPendingCellKey(null);
}, [stagedValues]);
const [ctxMenu, setCtxMenu] = useState<{ pos: DOMRect; row: number; col: number } | null>(null);
// Reset transient focus state when the data shape changes.
useEffect(() => {
setActiveCell(null);
setEditingCell(null);
setCtxMenu(null);
}, [rows.length, columns.length, hiddenColumns.size]);
// Document-level Escape: cancels in-cell editing even when the editor input
// has lost focus, and closes the context menu when open.
useEffect(() => {
const editing = editingCell != null;
const menuOpen = ctxMenu != null;
if (!editing && !menuOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
if (editing) {
setEditingCell(null);
setActiveCell(null);
}
if (menuOpen) setCtxMenu(null);
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [editingCell != null, ctxMenu != null]);
// ── column widths ───────────────────────────────────── // ── column widths ─────────────────────────────────────
const [colWidths, setColWidths] = useState<Record<string, number>>({}); const [colWidths, setColWidths] = useState<Record<string, number>>({});
@@ -138,44 +220,128 @@ export function VirtualDataGrid({
anchorRect: DOMRect | null; anchorRect: DOMRect | null;
} | null>(null); } | null>(null);
// ── keyboard navigation ───────────────────────────────
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (editingCell) return;
if (!activeCell) return;
const keyLabel = e.key === "Tab" ? (e.shiftKey ? "Shift+Tab" : "Tab") : e.key;
if (keyLabel.startsWith("Arrow") || keyLabel === "Tab" || keyLabel === "Shift+Tab") {
e.preventDefault();
const next = nextCell(activeCell, keyLabel, rows.length, visibleColumns.length);
setActiveCell(next);
virtualizer.scrollToIndex(next.row);
return;
}
if (e.key === "Enter") {
const col = visibleColumns[activeCell.col];
if (col && isCellEditable(col, tabType, dbType, readOnly)) {
e.preventDefault();
setEditingCell(activeCell);
}
return;
}
if (e.key === "Escape") {
e.preventDefault();
setActiveCell(null);
return;
}
if (e.key === "c" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
const col = visibleColumns[activeCell.col];
if (!col) return;
const ci = columns.findIndex((c) => c.name === col.name);
const value = rows[activeCell.row]?.[ci];
navigator.clipboard.writeText(String(value));
}
},
[activeCell, columns, dbType, editingCell, rows, tabType, visibleColumns, virtualizer],
);
// ── cell renderer (shared between header sizing and body) ── // ── cell renderer (shared between header sizing and body) ──
const renderCell = useCallback( const renderCell = useCallback(
(col: ColumnInfo, row: unknown[], _rowIndex: number) => { (col: ColumnInfo, row: unknown[], rowIndex: number, colIndex: number) => {
const ci = columns.findIndex((c) => c.name === col.name); const ci = columns.findIndex((c) => c.name === col.name);
const cell = ci >= 0 ? row[ci] : undefined; const cell = ci >= 0 ? row[ci] : undefined;
const isNull = cell === null || cell === undefined; const cellKey = `${rowIndex}:${col.name}`;
const stagedDefined = stagedValues ? cellKey in stagedValues : false;
const displayCell = stagedDefined
? stagedValues![cellKey]
: cell;
const displayIsNull =
displayCell === null || displayCell === undefined;
const isNull = displayIsNull;
const isFk = col.is_fk && col.fk_ref && !isNull; const isFk = col.is_fk && col.fk_ref && !isNull;
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(cell) : { label: "", isJson: false }; const jp = isJson ? jsonPreview(displayCell) : { label: "", isJson: false };
const editable = isCellEditable(col, tabType, dbType, readOnly);
const isActive = activeCell?.row === rowIndex && activeCell?.col === colIndex;
const isEditing = editingCell?.row === rowIndex && editingCell?.col === colIndex;
const isPending =
(pendingCell?.row === rowIndex && pendingCell?.col === colIndex) ||
pendingCellKey === cellKey ||
(pendingKeys ? cellKey in pendingKeys : false);
const handleJsonClick = (e: React.MouseEvent) => { const handleJsonClick = (e: React.MouseEvent) => {
if (isJson) { if (isJson) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setJsonPopover({ value: cell, anchorRect: rect }); setJsonPopover({ value: displayCell, anchorRect: rect });
} }
}; };
const commitEdit = (committed: string | null) => {
// oldData must be the DB value (the un-staged cell), so the queue's
// revert/display stays correct even after repeated edits of the same cell.
const dbValue = ci >= 0 ? row[ci] : undefined;
if (committed !== (dbValue === null || dbValue === undefined ? null : dbValue)) {
const locator = getLocator?.(row) ?? {};
onStageEdit?.(
cellToUpdateChange({
schema,
table,
primaryKey: locator,
oldData: { [col.name]: dbValue },
newData: { [col.name]: committed },
}) as {
type: "update";
schema: string;
table: string;
primaryKey: Record<string, unknown>;
oldData: Record<string, unknown>;
newData: Record<string, unknown>;
},
);
}
setPendingCellKey(cellKey);
setEditingCell(null);
};
return ( return (
<div <div
key={col.name} key={col.name}
className={`px-3 py-2 font-heading text-xs truncate select-text border-r border-border self-stretch ${ className={`relative px-3 py-2 font-heading text-xs truncate select-text border-r border-border self-stretch ${
isFk ? "cursor-pointer underline decoration-dotted underline-offset-2 hover:text-accent" : "" isFk ? "cursor-pointer underline decoration-dotted underline-offset-2 hover:text-accent" : ""
} ${isJson ? "cursor-pointer text-accent/80 hover:text-accent" : ""}`} } ${isJson ? "cursor-pointer text-accent/80 hover:text-accent" : ""} ${
role={isFk || isJson ? "button" : undefined} isActive ? "bg-accent/10 ring-1 ring-inset ring-accent outline-none" : ""
tabIndex={isFk || isJson ? 0 : undefined} }`}
role={isJson ? "button" : undefined}
tabIndex={isJson ? 0 : -1}
onKeyDown={ onKeyDown={
isFk || isJson isJson
? (e) => { ? (e) => {
if (e.key === "Enter" || e.key === " ") { if (e.key === "Enter" || e.key === " ") {
e.preventDefault(); e.preventDefault();
if (isFk) handleFkClick(col, cell, e as any);
else if (isJson) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setJsonPopover({ value: cell, anchorRect: rect }); setJsonPopover({ value: cell, anchorRect: rect });
} }
} }
}
: undefined : undefined
} }
style={{ width: getWidth(col.name), flexShrink: 0 }} style={{ width: getWidth(col.name), flexShrink: 0 }}
@@ -183,37 +349,128 @@ export function VirtualDataGrid({
isNull isNull
? "NULL" ? "NULL"
: isFk : isFk
? `FK → ${col.fk_ref![0]}.${col.fk_ref![1]}: ${String(cell)}` ? `FK → ${col.fk_ref![0]}.${col.fk_ref![1]}: ${String(displayCell)}`
: isJson : isJson
? "Click to view JSON" ? "Click to view JSON"
: String(cell) : String(displayCell)
}
onClick={
isFk
? (e) => handleFkClick(col, cell, e)
: isJson
? handleJsonClick
: undefined
} }
onClick={(e) => {
setActiveCell({ row: rowIndex, col: colIndex });
if (isJson) handleJsonClick(e);
}}
onDoubleClick={() => {
if (editable) setEditingCell({ row: rowIndex, col: colIndex });
}}
onContextMenu={(e) => {
e.preventDefault();
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setCtxMenu({ pos: rect, row: rowIndex, col: colIndex });
setActiveCell({ row: rowIndex, col: colIndex });
}}
> >
{isNull ? ( {isEditing ? (
<div className="absolute inset-0 z-20" onClick={(e) => e.stopPropagation()}>
<CellEditor
initialValue={isNull ? "" : String(displayCell)}
dataType={col.data_type}
nullable={col.is_nullable}
enumValues={enumValues?.[col.name]}
fkOptions={fkOptions?.[col.name]}
fkPlaceholder={fkPlaceholders?.[col.name]}
onCommit={commitEdit}
onCancel={() => setEditingCell(null)}
/>
</div>
) : isNull ? (
<span className="italic text-text-muted">NULL</span> <span className="italic text-text-muted">NULL</span>
) : isJson ? ( ) : isJson ? (
<span className="inline-flex items-center gap-0.5"> <span className="inline-flex items-center gap-0.5">
<Braces size={10} className="shrink-0" /> <Braces size={10} className="shrink-0" />
{jp.label} {jp.label}
</span> </span>
) : isFk && displayCell !== null && displayCell !== undefined ? (
<span className="inline-flex items-center gap-1 min-w-0">
<button
type="button"
aria-label="Open FK reference"
title={`FK → ${col.fk_ref![0]}.${col.fk_ref![1]}`}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleFkClick(col, displayCell, e);
}}
className="shrink-0 text-text-muted hover:text-accent"
>
<ArrowUpRight size={11} />
</button>
<span className="truncate">{String(displayCell)}</span>
</span>
) : ( ) : (
String(cell) String(displayCell)
)}
{isPending && (
<span
className="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-amber-400"
data-testid="pending-edit-dot"
/>
)} )}
</div> </div>
); );
}, },
[columns, getWidth, handleFkClick], [activeCell, columns, dbType, editingCell, enumValues, fkOptions, fkPlaceholders, getLocator, handleFkClick, onStageEdit, schema, table, tabType, getWidth, pendingCell, stagedValues, pendingKeys, pendingCellKey],
);
// ── context menu helpers ──────────────────────────────
const ctxCol = ctxMenu ? visibleColumns[ctxMenu.col] : null;
const copyCellValue = useCallback(
async (row: number, col: number) => {
const column = visibleColumns[col];
if (!column) return;
const ci = columns.findIndex((c) => c.name === column.name);
const value = rows[row]?.[ci];
await navigator.clipboard.writeText(String(value));
},
[columns, rows, visibleColumns],
);
const stageNull = useCallback(
(row: number, col: number) => {
const column = visibleColumns[col];
if (!column || !isCellEditable(column, tabType, dbType, readOnly)) return;
const ci = columns.findIndex((c) => c.name === column.name);
const value = rows[row]?.[ci];
if (value === null || value === undefined) return;
const locator = getLocator?.(rows[row]) ?? {};
onStageEdit?.(
cellToUpdateChange({
schema,
table,
primaryKey: locator,
oldData: { [column.name]: value },
newData: { [column.name]: null },
}) as {
type: "update";
schema: string;
table: string;
primaryKey: Record<string, unknown>;
oldData: Record<string, unknown>;
newData: Record<string, unknown>;
},
);
},
[columns, dbType, getLocator, onStageEdit, readOnly, rows, schema, table, tabType, visibleColumns],
); );
return ( return (
<div ref={parentRef} className="overflow-auto h-full" style={{ overscrollBehavior: "none" }}> <div
ref={parentRef}
className="overflow-auto h-full outline-none"
style={{ overscrollBehavior: "none" }}
tabIndex={-1}
role="grid"
onKeyDown={handleKeyDown}
>
{/* ── sticky header (hidden when no columns/table open) ── */} {/* ── sticky header (hidden when no columns/table open) ── */}
{hasColumns && ( {hasColumns && (
<div className="sticky top-0 z-10"> <div className="sticky top-0 z-10">
@@ -285,7 +542,10 @@ export function VirtualDataGrid({
}} }}
> >
{hasColumns && ( {hasColumns && (
<div style={{ width: 40, minWidth: 40 }} className="flex items-center justify-center border-r border-border self-stretch"> <div
style={{ width: 40, minWidth: 40 }}
className="flex items-center justify-center border-r border-border self-stretch"
>
<input <input
type="checkbox" type="checkbox"
checked={isSelected} checked={isSelected}
@@ -294,7 +554,7 @@ export function VirtualDataGrid({
/> />
</div> </div>
)} )}
{visibleColumns.map((col) => renderCell(col, row, virtualRow.index))} {visibleColumns.map((col, i) => renderCell(col, row, virtualRow.index, i))}
</div> </div>
); );
})} })}
@@ -321,6 +581,69 @@ export function VirtualDataGrid({
onClose={() => setJsonPopover(null)} onClose={() => setJsonPopover(null)}
/> />
)} )}
{/* Cell context menu */}
{ctxMenu && ctxCol && (
<>
{/* Click-outside-to-close backdrop (below the z-50 menu) */}
<div
className="fixed inset-0 z-40"
data-testid="ctx-backdrop"
onClick={() => setCtxMenu(null)}
/>
<CellContextMenu
anchorRect={ctxMenu.pos}
editable={isCellEditable(ctxCol, tabType, dbType, readOnly)}
isJson={ctxCol.data_type === "jsonb" || ctxCol.data_type === "json"}
isFk={ctxCol.is_fk && ctxCol.fk_ref != null}
nullable={ctxCol.is_nullable}
onCopy={() => {
void copyCellValue(ctxMenu.row, ctxMenu.col);
setCtxMenu(null);
}}
onCopyJson={() => {
void copyCellValue(ctxMenu.row, ctxMenu.col);
setCtxMenu(null);
}}
onViewRow={() => {
onOpenRowDetail?.(ctxMenu.row);
setCtxMenu(null);
}}
onSelectRow={() => {
onToggleRow(ctxMenu.row);
setCtxMenu(null);
}}
onEdit={() => {
setActiveCell({ row: ctxMenu.row, col: ctxMenu.col });
setEditingCell({ row: ctxMenu.row, col: ctxMenu.col });
setCtxMenu(null);
}}
onSetNull={() => {
stageNull(ctxMenu.row, ctxMenu.col);
setCtxMenu(null);
}}
onOpenFk={() => {
if (ctxCol?.is_fk && ctxCol.fk_ref) {
// Resolve the column index into `rows` (ctxMenu.col indexes visibleColumns,
// which can differ when columns are hidden).
const ci = columns.findIndex((c) => c.name === ctxCol.name);
const cellValue = rows[ctxMenu.row]?.[ci];
if (cellValue !== null && cellValue !== undefined) {
setFkPreview({
connectionId,
schema,
table: ctxCol.fk_ref[0],
column: ctxCol.fk_ref[1],
value: String(cellValue),
anchorRect: ctxMenu.pos,
});
}
}
setCtxMenu(null);
}}
onClose={() => setCtxMenu(null)}
/>
</>
)}
</div> </div>
); );
} }
@@ -0,0 +1,45 @@
import { describe, it, expect } from "vitest";
import { isCellEditable, defaultFilterOperator, cellToUpdateChange } from "./gridEditability";
import type { ColumnInfo } from "../../lib/types";
const col = (over: Partial<ColumnInfo> = {}): ColumnInfo => ({
name: "c", data_type: "text", is_nullable: true, is_pk: false, is_fk: false,
fk_ref: null, default_value: null, editable: true, is_generated: false, ...over,
});
describe("gridEditability", () => {
it("isCellEditable is false for PK, generated, non-editable flag, or view/tabType!=table", () => {
expect(isCellEditable(col({ is_pk: true }), "table", "postgresql")).toBe(false);
expect(isCellEditable(col({ is_generated: true }), "table", "postgresql")).toBe(false);
expect(isCellEditable(col({ editable: false }), "table", "postgresql")).toBe(false);
expect(isCellEditable(col(), "query", "postgresql")).toBe(false);
expect(isCellEditable(col(), "table", "mysql")).toBe(false);
expect(isCellEditable(col(), "table", "postgresql")).toBe(true);
expect(isCellEditable(col(), "table", "sqlite")).toBe(true);
});
it("defaultFilterOperator picks by data type", () => {
expect(defaultFilterOperator("text")).toBe("contains");
expect(defaultFilterOperator("uuid")).toBe("contains");
expect(defaultFilterOperator("integer")).toBe("eq");
expect(defaultFilterOperator("timestamptz")).toBe("eq");
expect(defaultFilterOperator("boolean")).toBe("eq");
expect(defaultFilterOperator("USER-DEFINED")).toBe("eq");
});
it("cellToUpdateChange builds the update payload using a locator when no PK", () => {
const out = cellToUpdateChange({
schema: "public", table: "users",
primaryKey: { id: 1 }, oldData: { name: "A" }, newData: { name: "B" },
});
expect(out).toEqual({ type: "update", schema: "public", table: "users",
primaryKey: { id: 1 }, oldData: { name: "A" }, newData: { name: "B" } });
});
it("cellToUpdateChange uses row locator as primaryKey when PK absent", () => {
const out = cellToUpdateChange({
schema: "public", table: "no_pk",
primaryKey: { ctid: "(0,1)" }, oldData: { name: "A" }, newData: { name: "B" },
});
expect(out.primaryKey).toEqual({ ctid: "(0,1)" });
});
});
+36
View File
@@ -0,0 +1,36 @@
import type { ColumnInfo, ChangeItemType } from "../../lib/types";
export type TabKind = "table" | "query";
export type EditableDbType = "postgresql" | "sqlite";
/** A cell is editable iff: table tab, PG/SQLite, column flagged editable, not PK, not generated, and not read-only. */
export function isCellEditable(col: ColumnInfo, tabType: TabKind, dbType: string, readOnly?: boolean): boolean {
if (readOnly) return false;
if (tabType !== "table") return false;
if (dbType !== "postgresql" && dbType !== "sqlite") return false;
if (!col.editable) return false;
if (col.is_pk) return false;
if (col.is_generated) return false;
return true;
}
/** Default filter operator inferred from column data type. */
export function defaultFilterOperator(dataType: string): "contains" | "eq" {
const t = dataType.toLowerCase();
const textish = ["text", "varchar", "char", "bpchar", "name", "uuid"];
if (textish.some((x) => t.includes(x))) return "contains";
return "eq";
}
/** Build the update change payload for a single edited cell. */
export function cellToUpdateChange(input: {
schema: string; table: string;
primaryKey: Record<string, unknown>;
oldData: Record<string, unknown>;
newData: Record<string, unknown>;
}): { type: ChangeItemType; schema: string; table: string;
primaryKey: Record<string, unknown>;
oldData: Record<string, unknown>;
newData: Record<string, unknown> } {
return { type: "update", ...input };
}
+25
View File
@@ -0,0 +1,25 @@
import { describe, it, expect } from "vitest";
import { nextCell } from "./keyboardNav";
describe("keyboardNav", () => {
it("ArrowRight moves right, clamps at last col", () => {
expect(nextCell({ row: 0, col: 0 }, "ArrowRight", 5, 3)).toEqual({ row: 0, col: 1 });
expect(nextCell({ row: 0, col: 2 }, "ArrowRight", 5, 3)).toEqual({ row: 0, col: 2 });
});
it("ArrowLeft moves left, clamps at 0", () => {
expect(nextCell({ row: 1, col: 1 }, "ArrowLeft", 5, 3)).toEqual({ row: 1, col: 0 });
expect(nextCell({ row: 1, col: 0 }, "ArrowLeft", 5, 3)).toEqual({ row: 1, col: 0 });
});
it("ArrowDown/ArrowUp move row, clamp", () => {
expect(nextCell({ row: 0, col: 1 }, "ArrowDown", 5, 3)).toEqual({ row: 1, col: 1 });
expect(nextCell({ row: 4, col: 1 }, "ArrowDown", 5, 3)).toEqual({ row: 4, col: 1 });
expect(nextCell({ row: 4, col: 1 }, "ArrowUp", 5, 3)).toEqual({ row: 3, col: 1 });
});
it("Tab wraps to next row; Shift+Tab wraps back", () => {
expect(nextCell({ row: 0, col: 2 }, "Tab", 5, 3)).toEqual({ row: 1, col: 0 });
expect(nextCell({ row: 1, col: 0 }, "Shift+Tab", 5, 3)).toEqual({ row: 0, col: 2 });
});
it("unknown key returns same cell", () => {
expect(nextCell({ row: 1, col: 1 }, "x", 5, 3)).toEqual({ row: 1, col: 1 });
});
});
+26
View File
@@ -0,0 +1,26 @@
export interface CellPos { row: number; col: number; }
export function nextCell(pos: CellPos, key: string, rowCount: number, colCount: number): CellPos {
let { row, col } = pos;
switch (key) {
case "ArrowRight":
col = Math.min(col + 1, colCount - 1); break;
case "ArrowLeft":
col = Math.max(col - 1, 0); break;
case "ArrowDown":
row = Math.min(row + 1, rowCount - 1); break;
case "ArrowUp":
row = Math.max(row - 1, 0); break;
case "Tab":
if (col + 1 < colCount) col += 1;
else { col = 0; row = Math.min(row + 1, rowCount - 1); }
break;
case "Shift+Tab":
if (col - 1 >= 0) col -= 1;
else { col = colCount - 1; row = Math.max(row - 1, 0); }
break;
default:
return pos;
}
return { row, col };
}
+9 -2
View File
@@ -1,10 +1,10 @@
import { describe, it, expect, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { ActionRow } from "./ActionRow"; import { ActionRow } from "./ActionRow";
import { useUiStore } from "../../stores/uiStore"; import { useUiStore } from "../../stores/uiStore";
beforeEach(() => useUiStore.setState({ activeView: "home" })); beforeEach(() => useUiStore.setState({ activeView: "home", selectedItemIds: [] }));
describe("ActionRow", () => { describe("ActionRow", () => {
it("renders Saved Connections title", () => { it("renders Saved Connections title", () => {
@@ -21,4 +21,11 @@ describe("ActionRow", () => {
await userEvent.click(screen.getByText(/settings/i)); await userEvent.click(screen.getByText(/settings/i));
expect(useUiStore.getState().activeView).toBe("settings"); expect(useUiStore.getState().activeView).toBe("settings");
}); });
it("shows a Move to folder button when selection exists and calls onMoveToFolder", async () => {
useUiStore.setState({ selectedItemIds: ["c1"] });
const onMoveToFolder = vi.fn();
render(<ActionRow onMoveToFolder={onMoveToFolder} />);
await userEvent.click(screen.getByRole("button", { name: /move to folder/i }));
expect(onMoveToFolder).toHaveBeenCalled();
});
}); });
+8 -2
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Plus, Settings as SettingsIcon, FolderPlus, Trash2, Check, X, ChevronDown } from "lucide-react"; import { Plus, Settings as SettingsIcon, FolderPlus, FolderInput, Trash2, Check, X, ChevronDown } from "lucide-react";
import { Button } from "../ui/Button"; import { Button } from "../ui/Button";
import { useUiStore } from "../../stores/uiStore"; import { useUiStore } from "../../stores/uiStore";
import { ImportExportMenu } from "./ImportExportMenu"; import { ImportExportMenu } from "./ImportExportMenu";
@@ -12,10 +12,11 @@ interface ActionRowProps {
onNewFolder?: () => void; onNewFolder?: () => void;
onFilters?: () => void; onFilters?: () => void;
onDeleteSelected?: () => void; onDeleteSelected?: () => void;
onMoveToFolder?: () => void;
visibleItemIds?: string[]; visibleItemIds?: string[];
} }
export function ActionRow({ onImport, onExport, onNewFolder, onFilters: _onFilters, onDeleteSelected, visibleItemIds = [] }: ActionRowProps) { export function ActionRow({ onImport, onExport, onNewFolder, onFilters: _onFilters, onDeleteSelected, onMoveToFolder, visibleItemIds = [] }: ActionRowProps) {
const setActiveView = useUiStore((s) => s.setActiveView); const setActiveView = useUiStore((s) => s.setActiveView);
const openSettings = useUiStore((s) => s.openSettings); const openSettings = useUiStore((s) => s.openSettings);
const selectedItemIds = useUiStore((s) => s.selectedItemIds); const selectedItemIds = useUiStore((s) => s.selectedItemIds);
@@ -52,6 +53,11 @@ export function ActionRow({ onImport, onExport, onNewFolder, onFilters: _onFilte
<Button variant="ghost" className="text-xs border-0" onClick={onNewFolder ?? (() => {})}> <Button variant="ghost" className="text-xs border-0" onClick={onNewFolder ?? (() => {})}>
<FolderPlus size={14} /> New Folder <FolderPlus size={14} /> New Folder
</Button> </Button>
{hasSelection && (
<Button variant="ghost" className="text-xs border-0" onClick={onMoveToFolder}>
<FolderInput size={14} /> Move to folder
</Button>
)}
{hasSelection && ( {hasSelection && (
<div className="relative" ref={menuRef}> <div className="relative" ref={menuRef}>
<Button variant="ghost" className="text-xs border-0" onClick={() => setMenuOpen((o) => !o)}> <Button variant="ghost" className="text-xs border-0" onClick={() => setMenuOpen((o) => !o)}>
+82 -2
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { HomeScreen } from "./HomeScreen"; import { HomeScreen } from "./HomeScreen";
import { useConnectionStore } from "../../stores/connectionStore"; import { useConnectionStore } from "../../stores/connectionStore";
@@ -12,6 +12,8 @@ vi.mock("../../lib/commands", () => ({
getFolders: vi.fn().mockResolvedValue([]), getFolders: vi.fn().mockResolvedValue([]),
getTags: vi.fn().mockResolvedValue([]), getTags: vi.fn().mockResolvedValue([]),
getSettings: vi.fn().mockResolvedValue({}), getSettings: vi.fn().mockResolvedValue({}),
getRecentConnections: vi.fn().mockResolvedValue([{ connection_id: "recent-1", opened_at: "" }]),
recordRecentConnection: vi.fn().mockResolvedValue(undefined),
deleteConnection: vi.fn().mockResolvedValue(undefined), deleteConnection: vi.fn().mockResolvedValue(undefined),
deleteConnectionPassword: vi.fn().mockResolvedValue(undefined), deleteConnectionPassword: vi.fn().mockResolvedValue(undefined),
deleteFolder: vi.fn().mockResolvedValue(undefined), deleteFolder: vi.fn().mockResolvedValue(undefined),
@@ -25,7 +27,7 @@ vi.mock("@tauri-apps/plugin-fs", () => ({
describe("HomeScreen", () => { describe("HomeScreen", () => {
beforeEach(() => { beforeEach(() => {
useConnectionStore.setState({ connections: [], folders: [], tags: [], loading: false, error: null }); useConnectionStore.setState({ connections: [], folders: [], tags: [], recent: [], loading: false, error: null });
useUiStore.setState({ searchQuery: "", activeFolderId: null, activeTagIds: [], activeDbTypes: [], activeView: "home", selectedItemIds: [] }); useUiStore.setState({ searchQuery: "", activeFolderId: null, activeTagIds: [], activeDbTypes: [], activeView: "home", selectedItemIds: [] });
useSettingsStore.setState({ settings: null, loading: false, error: null }); useSettingsStore.setState({ settings: null, loading: false, error: null });
// SearchBar stores its debounce timer on window.__sb; clear any timer leaked // SearchBar stores its debounce timer on window.__sb; clear any timer leaked
@@ -125,6 +127,83 @@ describe("HomeScreen", () => {
expect(screen.queryByText(/are you sure you want to delete/i)).not.toBeInTheDocument(); expect(screen.queryByText(/are you sure you want to delete/i)).not.toBeInTheDocument();
expect(screen.queryByTestId("animated-backdrop")).not.toBeInTheDocument(); expect(screen.queryByTestId("animated-backdrop")).not.toBeInTheDocument();
}); });
it("records a recent connection when opening a connection", async () => {
const user = userEvent.setup();
useConnectionStore.setState({ connections: [makeConnection("conn-1")] });
render(<HomeScreen />);
await user.click(screen.getByText("Local DB"));
const { recordRecentConnection } = await import("../../lib/commands");
await waitFor(() =>
expect(recordRecentConnection).toHaveBeenCalledWith("conn-1"),
);
});
it("renders the recent connections strip at the root when recents exist", async () => {
useConnectionStore.setState({ connections: [makeConnection("recent-1")] });
useUiStore.setState({ activeFolderId: null, searchQuery: "" });
render(<HomeScreen />);
await waitFor(() => {
expect(screen.getByText("Recent")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /local db/i })).toBeInTheDocument();
});
});
it("opens EditConnectionModal from the connection card kebab menu", async () => {
const user = userEvent.setup();
vi.clearAllMocks();
useConnectionStore.setState({ connections: [makeConnection("conn-1")] });
render(<HomeScreen />);
await user.click(screen.getByLabelText("Connection actions"));
await user.click(screen.getByText("Manage"));
await user.click(screen.getByText("Edit…"));
expect(await screen.findByText("Edit Connection")).toBeInTheDocument();
expect(screen.getByDisplayValue("Local DB")).toBeInTheDocument();
});
it("shows the confirmation dialog before deleting a connection from the kebab menu by default", async () => {
const user = userEvent.setup();
vi.clearAllMocks();
useConnectionStore.setState({ connections: [makeConnection("conn-1")] });
render(<HomeScreen />);
await user.click(screen.getByLabelText("Connection actions"));
await user.click(screen.getByText("Manage"));
await user.click(screen.getByText("Delete…"));
expect(
await screen.findByText(/are you sure you want to delete \"Local DB\"/i),
).toBeInTheDocument();
const { deleteConnection } = await import("../../lib/commands");
expect(deleteConnection).not.toHaveBeenCalled();
});
it("deletes a connection from the kebab menu without confirmation when confirm_before_delete is false", async () => {
const user = userEvent.setup();
vi.clearAllMocks();
useSettingsStore.setState({
settings: {
...baseSettings(),
confirm_before_delete: false,
},
});
useConnectionStore.setState({ connections: [makeConnection("conn-1")] });
render(<HomeScreen />);
await user.click(screen.getByLabelText("Connection actions"));
await user.click(screen.getByText("Manage"));
await user.click(screen.getByText("Delete…"));
const { deleteConnection } = await import("../../lib/commands");
await waitFor(() =>
expect(deleteConnection).toHaveBeenCalledWith("conn-1"),
);
expect(
screen.queryByText(/are you sure you want to delete/i),
).not.toBeInTheDocument();
});
}); });
function makeConnection(id: string): Connection { function makeConnection(id: string): Connection {
@@ -138,6 +217,7 @@ function makeConnection(id: string): Connection {
folder_id: null, folder_id: null,
keychain_ref: null, keychain_ref: null,
tag_ids: [], tag_ids: [],
favorite: false,
created_at: "", created_at: "",
updated_at: "", updated_at: "",
}; };
+90 -2
View File
@@ -13,10 +13,13 @@ import { ConnectionCard } from "../connections/ConnectionCard";
import { CreateFolderDialog } from "../folders/CreateFolderDialog"; import { CreateFolderDialog } from "../folders/CreateFolderDialog";
import { EditFolderDialog } from "../folders/EditFolderDialog"; import { EditFolderDialog } from "../folders/EditFolderDialog";
import { ConfirmDialog } from "../ui/ConfirmDialog"; import { ConfirmDialog } from "../ui/ConfirmDialog";
import { EditConnectionModal } from "../db-viewer/EditConnectionModal";
import { MoveToFolderDialog } from "../connections/MoveToFolderDialog";
import { RecentConnectionsStrip } from "../connections/RecentConnectionsStrip";
import { handleImport, handleExport } from "../../lib/importExport"; import { handleImport, handleExport } from "../../lib/importExport";
import { getChildFolders } from "../../lib/utils"; import { getChildFolders } from "../../lib/utils";
import { useShortcut } from "../../hooks/useShortcut"; import { useShortcut } from "../../hooks/useShortcut";
import type { Folder } from "../../lib/types"; import type { Connection, Folder } from "../../lib/types";
export function HomeScreen() { export function HomeScreen() {
const connections = useFilteredConnections(); const connections = useFilteredConnections();
@@ -31,15 +34,22 @@ export function HomeScreen() {
const deleteFolder = useConnectionStore((s) => s.deleteFolder); const deleteFolder = useConnectionStore((s) => s.deleteFolder);
const deleteConnection = useConnectionStore((s) => s.deleteConnection); const deleteConnection = useConnectionStore((s) => s.deleteConnection);
const loadAll = useConnectionStore((s) => s.loadAll); const loadAll = useConnectionStore((s) => s.loadAll);
const moveSelectionToFolder = useConnectionStore((s) => s.moveSelectionToFolder);
const recordRecent = useConnectionStore((s) => s.recordRecent);
const loadRecent = useConnectionStore((s) => s.loadRecent);
const recent = useConnectionStore((s) => s.recent);
const selectedItemIds = useUiStore((s) => s.selectedItemIds); const selectedItemIds = useUiStore((s) => s.selectedItemIds);
const clearSelection = useUiStore((s) => s.clearSelection); const clearSelection = useUiStore((s) => s.clearSelection);
const confirmBeforeDelete = const confirmBeforeDelete =
useSettingsStore((s) => s.settings?.confirm_before_delete ?? true); useSettingsStore((s) => s.settings?.confirm_before_delete ?? true);
const [folderDialogOpen, setFolderDialogOpen] = useState(false); const [folderDialogOpen, setFolderDialogOpen] = useState(false);
const [editFolder, setEditFolder] = useState<Folder | null>(null); const [editFolder, setEditFolder] = useState<Folder | null>(null);
const [moveToFolderOpen, setMoveToFolderOpen] = useState(false);
const [editingConnection, setEditingConnection] = useState<Connection | null>(null);
const [confirmDelete, setConfirmDelete] = useState<{ const [confirmDelete, setConfirmDelete] = useState<{
type: "folder" | "selected"; type: "folder" | "selected" | "connection";
folder?: Folder; folder?: Folder;
connection?: Connection;
} | null>(null); } | null>(null);
const [activeDragId, setActiveDragId] = useState<string | null>(null); const [activeDragId, setActiveDragId] = useState<string | null>(null);
const searchRef = useRef<SearchBarHandle>(null); const searchRef = useRef<SearchBarHandle>(null);
@@ -51,6 +61,7 @@ export function HomeScreen() {
const setActiveConnectionId = useUiStore((s) => s.setActiveConnectionId); const setActiveConnectionId = useUiStore((s) => s.setActiveConnectionId);
const handleOpenDbViewer = (connectionId: string) => { const handleOpenDbViewer = (connectionId: string) => {
recordRecent(connectionId);
setActiveConnectionId(connectionId); setActiveConnectionId(connectionId);
setActiveView("db-viewer"); setActiveView("db-viewer");
}; };
@@ -119,6 +130,11 @@ export function HomeScreen() {
} }
}, [folders, activeFolderId, setActiveFolderId]); }, [folders, activeFolderId, setActiveFolderId]);
// Load recent connections on mount for the root strip
useEffect(() => {
loadRecent();
}, [loadRecent]);
const executeDeleteSelected = async () => { const executeDeleteSelected = async () => {
const folderIds = new Set(folders.map((f) => f.id)); const folderIds = new Set(folders.map((f) => f.id));
for (const id of selectedItemIds) { for (const id of selectedItemIds) {
@@ -148,6 +164,24 @@ export function HomeScreen() {
setConfirmDelete(null); setConfirmDelete(null);
}; };
const handleDuplicateConnection = async (conn: Connection) => {
try {
await useConnectionStore.getState().duplicateConnection(conn.id);
} catch (e) {
console.error("Failed to duplicate connection:", e);
}
};
const handleDeleteConnection = (conn: Connection) => {
if (confirmBeforeDelete) {
setConfirmDelete({ type: "connection", connection: conn });
} else {
void deleteConnection(conn.id).catch((e) => {
console.error("Failed to delete connection:", e);
});
}
};
return ( return (
<main className="min-h-full p-6 bg-canvas select-none max-w-7xl mx-auto"> <main className="min-h-full p-6 bg-canvas select-none max-w-7xl mx-auto">
<div className="mb-6"> <div className="mb-6">
@@ -168,9 +202,13 @@ export function HomeScreen() {
? setConfirmDelete({ type: "selected" }) ? setConfirmDelete({ type: "selected" })
: executeDeleteSelected() : executeDeleteSelected()
} }
onMoveToFolder={() => setMoveToFolderOpen(true)}
visibleItemIds={visibleItemIds} visibleItemIds={visibleItemIds}
/> />
</div> </div>
{activeFolderId === null && !searchQuery && (
<RecentConnectionsStrip recents={recent} onOpen={handleOpenDbViewer} />
)}
<DndContext <DndContext
onDragStart={(event) => setActiveDragId(event.active.id as string)} onDragStart={(event) => setActiveDragId(event.active.id as string)}
onDragEnd={async (event) => { onDragEnd={async (event) => {
@@ -194,6 +232,9 @@ export function HomeScreen() {
? setConfirmDelete({ type: "folder", folder: f }) ? setConfirmDelete({ type: "folder", folder: f })
: executeDeleteFolder(f) : executeDeleteFolder(f)
} }
onEditConnection={setEditingConnection}
onDuplicateConnection={handleDuplicateConnection}
onDeleteConnection={handleDeleteConnection}
/> />
<DragOverlay dropAnimation={null}> <DragOverlay dropAnimation={null}>
{activeDragId && connections.find((c) => c.id === activeDragId) ? ( {activeDragId && connections.find((c) => c.id === activeDragId) ? (
@@ -242,6 +283,29 @@ export function HomeScreen() {
}} }}
onClose={() => setEditFolder(null)} onClose={() => setEditFolder(null)}
/> />
<MoveToFolderDialog
open={moveToFolderOpen}
folders={folders}
selectedCount={selectedItemIds.length}
onConfirm={async (target) => {
try {
await moveSelectionToFolder(selectedItemIds, target);
} catch (e) {
console.error("Failed to move selection:", e);
}
clearSelection();
setMoveToFolderOpen(false);
}}
onClose={() => setMoveToFolderOpen(false)}
/>
{editingConnection && (
<EditConnectionModal
connection={editingConnection}
open
onClose={() => setEditingConnection(null)}
onSaved={() => {}}
/>
)}
{confirmDelete?.type === "selected" && ( {confirmDelete?.type === "selected" && (
<ConfirmDialog <ConfirmDialog
open open
@@ -264,6 +328,30 @@ export function HomeScreen() {
onCancel={() => setConfirmDelete(null)} onCancel={() => setConfirmDelete(null)}
/> />
)} )}
{confirmDelete?.type === "connection" &&
confirmDelete.connection && (
<ConfirmDialog
open
title="Delete Connection"
message={`Are you sure you want to delete "${confirmDelete.connection.name}"?`}
confirmLabel="Delete"
confirmVariant="ghost"
onConfirm={async () => {
try {
await deleteConnection(
confirmDelete.connection!.id,
);
} catch (e) {
console.error(
"Failed to delete connection:",
e,
);
}
setConfirmDelete(null);
}}
onCancel={() => setConfirmDelete(null)}
/>
)}
</main> </main>
); );
} }
+10
View File
@@ -28,4 +28,14 @@ describe("SearchBar", () => {
fireEvent.change(input, { target: { value: "postgresql://user@host/db" } }); fireEvent.change(input, { target: { value: "postgresql://user@host/db" } });
expect(onDetectUrl).toHaveBeenCalledWith("postgresql://user@host/db"); expect(onDetectUrl).toHaveBeenCalledWith("postgresql://user@host/db");
}); });
it("clears the search and exits search mode on Escape", () => {
useUiStore.setState({ searchQuery: "prod" });
render(<SearchBar />);
const input = screen.getByPlaceholderText(/search/i);
// input mirrors the store value
expect((input as HTMLInputElement).value).toBe("prod");
fireEvent.keyDown(input, { key: "Escape" });
expect(useUiStore.getState().searchQuery).toBe("");
});
}); });
+9
View File
@@ -44,6 +44,15 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(function Se
window.clearTimeout((window as any).__sb); window.clearTimeout((window as any).__sb);
(window as any).__sb = window.setTimeout(() => setSearchQuery(v), 150); (window as any).__sb = window.setTimeout(() => setSearchQuery(v), 150);
}} }}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
window.clearTimeout((window as any).__sb);
setValue("");
setSearchQuery("");
inputRef.current?.blur();
}
}}
className="pl-10 pr-14" className="pl-10 pr-14"
/> />
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-0.5 px-1.5 py-0.5 rounded border border-border bg-surface-raised text-text-muted text-xs pointer-events-none"> <div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-0.5 px-1.5 py-0.5 rounded border border-border bg-surface-raised text-text-muted text-xs pointer-events-none">
+1
View File
@@ -30,6 +30,7 @@ const makeConn = (overrides: Partial<Connection> = {}): Connection => ({
ssl_ca_path: null, ssl_ca_path: null,
ssl_cert_path: null, ssl_cert_path: null,
ssl_key_path: null, ssl_key_path: null,
favorite: false,
...overrides, ...overrides,
}); });
+56
View File
@@ -24,6 +24,12 @@ import {
getSavedQueries, getSavedQueries,
updateSavedQuery, updateSavedQuery,
deleteSavedQuery, deleteSavedQuery,
setConnectionFavorite,
recordRecentConnection,
getRecentConnections,
clearRecentConnections,
getIndexes,
getConstraints,
} from "./commands"; } from "./commands";
import type { SchemaGraph } from "./types"; import type { SchemaGraph } from "./types";
import type { QueryHistoryEntry } from "./commands"; import type { QueryHistoryEntry } from "./commands";
@@ -236,3 +242,53 @@ describe("Query History — v6", () => {
}); });
}); });
}); });
describe("v0.5.0 command wrappers", () => {
it("setConnectionFavorite invokes set_connection_favorite with camelCase", async () => {
const mockInvoke = vi.fn().mockResolvedValue(undefined);
vi.mocked(invoke).mockImplementation(mockInvoke);
await setConnectionFavorite("c1", true);
expect(mockInvoke).toHaveBeenCalledWith("set_connection_favorite", { connectionId: "c1", favorite: true });
});
it("recordRecentConnection invokes record_recent_connection", async () => {
const mockInvoke = vi.fn().mockResolvedValue(undefined);
vi.mocked(invoke).mockImplementation(mockInvoke);
await recordRecentConnection("c1");
expect(mockInvoke).toHaveBeenCalledWith("record_recent_connection", { connectionId: "c1" });
});
it("getRecentConnections invokes get_recent_connections with limit", async () => {
const mockInvoke = vi.fn().mockResolvedValue([]);
vi.mocked(invoke).mockImplementation(mockInvoke);
await getRecentConnections(8);
expect(mockInvoke).toHaveBeenCalledWith("get_recent_connections", { limit: 8 });
});
it("clearRecentConnections invokes clear_recent_connections", async () => {
const mockInvoke = vi.fn().mockResolvedValue(undefined);
vi.mocked(invoke).mockImplementation(mockInvoke);
await clearRecentConnections();
expect(mockInvoke).toHaveBeenCalledWith("clear_recent_connections", {});
});
it("getIndexes invokes get_indexes with connectionId + schema", async () => {
const mockInvoke = vi.fn().mockResolvedValue([]);
vi.mocked(invoke).mockImplementation(mockInvoke);
await getIndexes("c1", "public");
expect(mockInvoke).toHaveBeenCalledWith("get_indexes", { connectionId: "c1", schema: "public" });
});
it("getConstraints invokes get_constraints with connectionId + schema", async () => {
const mockInvoke = vi.fn().mockResolvedValue([]);
vi.mocked(invoke).mockImplementation(mockInvoke);
await getConstraints("c1", "public");
expect(mockInvoke).toHaveBeenCalledWith("get_constraints", { connectionId: "c1", schema: "public" });
});
});
+27 -1
View File
@@ -1,5 +1,5 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph } from "./types"; import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph, IndexInfo, ConstraintInfo, RecentConnection } from "./types";
import type { FilterRule, SortRule } from "../stores/dbViewerStore"; import type { FilterRule, SortRule } from "../stores/dbViewerStore";
import type { ChangePayload } from "./changePayload"; import type { ChangePayload } from "./changePayload";
@@ -269,3 +269,29 @@ export async function updateSavedQuery(
export async function deleteSavedQuery(id: string): Promise<void> { export async function deleteSavedQuery(id: string): Promise<void> {
return invoke<void>("delete_saved_query", { id }); return invoke<void>("delete_saved_query", { id });
} }
// ─── v0.5.0: Favorites / Recents / Indexes / Constraints ──────────
export async function setConnectionFavorite(connectionId: string, favorite: boolean): Promise<void> {
return invoke<void>("set_connection_favorite", { connectionId, favorite });
}
export async function recordRecentConnection(connectionId: string): Promise<void> {
return invoke<void>("record_recent_connection", { connectionId });
}
export async function getRecentConnections(limit: number): Promise<RecentConnection[]> {
return invoke<RecentConnection[]>("get_recent_connections", { limit });
}
export async function clearRecentConnections(): Promise<void> {
return invoke<void>("clear_recent_connections", {});
}
export async function getIndexes(connectionId: string, schema?: string): Promise<IndexInfo[]> {
return invoke<IndexInfo[]>("get_indexes", { connectionId, schema });
}
export async function getConstraints(connectionId: string, schema?: string): Promise<ConstraintInfo[]> {
return invoke<ConstraintInfo[]>("get_constraints", { connectionId, schema });
}
+35
View File
@@ -0,0 +1,35 @@
import { describe, it, expect } from "vitest";
// Import the docs as raw strings (vite/client declares `*?raw`); this keeps the
// test free of a `node:fs` dependency so `tsc` (bun run build) stays clean.
import agents from "../../AGENTS.md?raw";
import readme from "../../README.md?raw";
describe("v0.5.0 docs coverage", () => {
it("AGENTS.md marks inline cell editing complete", () => {
expect(agents).toContain("Inline cell editing");
expect(agents).toMatch(/Inline cell editing \| ✅/);
});
it("AGENTS.md marks indexes + constraints complete", () => {
expect(agents).toMatch(/Indexes \(per table\) \| ✅/);
expect(agents).toMatch(/Constraints \(CHECK, UNIQUE beyond PK\/FK\) \| ✅/);
});
it("AGENTS.md marks materialized views complete", () => {
expect(agents).toMatch(/Materialized views \| ✅/);
});
it("AGENTS.md marks stored procedures complete", () => {
expect(agents).toMatch(/Stored procedures \| ✅/);
});
it("AGENTS.md marks favorites + recents + status indicator complete", () => {
expect(agents).toMatch(/Favorites \/ Recent connections \| ✅/);
expect(agents).toMatch(/Connection status indicator on cards \| ✅/);
expect(agents).toMatch(/Move-to-folder bulk action \| ✅/);
});
it("README declares v0.5.0", () => {
expect(readme).toContain("0.5.0");
});
it("README marks inline editing complete (not Upcoming)", () => {
// Gridline's comparison-table cell carries the ✅ marker
expect(readme).toMatch(/Inline cell editing \| ✅ \| ✅ \| \*\*✅/);
expect(readme).not.toMatch(/Inline cell editing.*Upcoming/);
});
});
+2 -2
View File
@@ -3,8 +3,8 @@ import { exportData } from "./exportData";
import type { ColumnInfo } from "./types"; import type { ColumnInfo } from "./types";
const columns: ColumnInfo[] = [ const columns: ColumnInfo[] = [
{ name: "id", data_type: "int", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null }, { name: "id", data_type: "int", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
{ name: "v", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null }, { name: "v", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
]; ];
describe("exportData", () => { describe("exportData", () => {
+18
View File
@@ -0,0 +1,18 @@
import { describe, it, expect } from "vitest";
import { pruneRecent, dedupeRecent } from "./recentConnections";
import type { RecentConnection } from "./types";
const rc = (id: string, at: string): RecentConnection => ({ connection_id: id, opened_at: at });
describe("recentConnections helpers", () => {
it("pruneRecent keeps the newest N", () => {
const list = [rc("a", "1"), rc("b", "3"), rc("c", "2")];
expect(pruneRecent(list, 2)).toEqual([rc("b", "3"), rc("c", "2")]);
});
it("dedupeRecent moves the latest occurrence of an id to the front", () => {
const list = [rc("a", "1"), rc("b", "2"), rc("a", "3")];
const out = dedupeRecent(list);
expect(out[0].connection_id).toBe("a");
expect(out).toHaveLength(2);
});
});
+19
View File
@@ -0,0 +1,19 @@
import type { RecentConnection } from "./types";
/** Keep the newest N entries (sorted by opened_at DESC). */
export function pruneRecent(list: RecentConnection[], n: number): RecentConnection[] {
return [...list].sort((a, b) => (a.opened_at < b.opened_at ? 1 : -1)).slice(0, n);
}
/** Remove duplicates, keeping the most recent occurrence per connection_id at the front. */
export function dedupeRecent(list: RecentConnection[]): RecentConnection[] {
const seen = new Set<string>();
const out: RecentConnection[] = [];
for (const item of [...list].sort((a, b) => (a.opened_at < b.opened_at ? 1 : -1))) {
if (!seen.has(item.connection_id)) {
seen.add(item.connection_id);
out.push(item);
}
}
return out;
}
+47 -4
View File
@@ -1,4 +1,4 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect, expectTypeOf } from "vitest";
import type { import type {
Connection, Connection,
ConnectionInput, ConnectionInput,
@@ -16,6 +16,9 @@ import type {
GraphColumn, GraphColumn,
Relationship, Relationship,
Settings, Settings,
IndexInfo,
ConstraintInfo,
RecentConnection,
} from "./types"; } from "./types";
describe("ActiveView", () => { describe("ActiveView", () => {
@@ -37,6 +40,7 @@ describe("Connection", () => {
folder_id: null, folder_id: null,
keychain_ref: null, keychain_ref: null,
tag_ids: [], tag_ids: [],
favorite: false,
created_at: "2024-01-01T00:00:00Z", created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z", updated_at: "2024-01-01T00:00:00Z",
// new SSH/SSL fields // new SSH/SSL fields
@@ -68,6 +72,7 @@ describe("Connection", () => {
folder_id: null, folder_id: null,
keychain_ref: null, keychain_ref: null,
tag_ids: [], tag_ids: [],
favorite: false,
created_at: "2024-01-01T00:00:00Z", created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z", updated_at: "2024-01-01T00:00:00Z",
}; };
@@ -89,6 +94,7 @@ describe("Connection", () => {
folder_id: null, folder_id: null,
keychain_ref: null, keychain_ref: null,
tag_ids: [], tag_ids: [],
favorite: false,
created_at: "2024-01-01T00:00:00Z", created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z", updated_at: "2024-01-01T00:00:00Z",
}; };
@@ -184,6 +190,8 @@ describe("ColumnInfo", () => {
is_fk: false, is_fk: false,
fk_ref: null, fk_ref: null,
default_value: null, default_value: null,
editable: true,
is_generated: false,
}; };
expect(col.name).toBe("id"); expect(col.name).toBe("id");
expect(col.is_pk).toBe(true); expect(col.is_pk).toBe(true);
@@ -198,6 +206,8 @@ describe("ColumnInfo", () => {
is_fk: true, is_fk: true,
fk_ref: ["users", "id"], fk_ref: ["users", "id"],
default_value: null, default_value: null,
editable: true,
is_generated: false,
}; };
expect(col.fk_ref?.[0]).toBe("users"); expect(col.fk_ref?.[0]).toBe("users");
}); });
@@ -206,7 +216,7 @@ describe("ColumnInfo", () => {
describe("QueryResult", () => { describe("QueryResult", () => {
it("is well-typed with columns and rows", () => { it("is well-typed with columns and rows", () => {
const result: QueryResult = { const result: QueryResult = {
columns: [{name:"id",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null},{name:"name",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null},{name:"email",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null}], columns: [{name:"id",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null,editable:true,is_generated:false},{name:"name",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null,editable:true,is_generated:false},{name:"email",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null,editable:true,is_generated:false}],
rows: [ rows: [
[1, "Alice"], [1, "Alice"],
[2, "Bob"], [2, "Bob"],
@@ -231,7 +241,7 @@ describe("QueryResult", () => {
it("can have null execution_time", () => { it("can have null execution_time", () => {
const result: QueryResult = { const result: QueryResult = {
columns: [{name:"id",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null}], columns: [{name:"id",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null,editable:true,is_generated:false}],
rows: [], rows: [],
total_rows: 0, page: 1, page_size: 50, total_rows: 0, page: 1, page_size: 50,
execution_time_ms: null, execution_time_ms: null,
@@ -324,7 +334,7 @@ describe("DbViewerTab", () => {
title: "SELECT * FROM users", title: "SELECT * FROM users",
query: "SELECT * FROM users", query: "SELECT * FROM users",
result: { result: {
columns: [{name:"id",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null},{name:"name",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null}], columns: [{name:"id",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null,editable:true,is_generated:false},{name:"name",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null,editable:true,is_generated:false}],
rows: [], rows: [],
total_rows: 0, page: 1, page_size: 50, total_rows: 0, page: 1, page_size: 50,
}, },
@@ -453,3 +463,36 @@ describe("Settings", () => {
expect(s.editor_word_wrap).toBe("off"); expect(s.editor_word_wrap).toBe("off");
}); });
}); });
describe("v0.5.0 types", () => {
it("ColumnInfo carries editability metadata", () => {
const c: 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,
};
expectTypeOf(c.editable).toEqualTypeOf<boolean>();
expectTypeOf(c.is_generated).toEqualTypeOf<boolean>();
});
it("IndexInfo has the documented fields", () => {
const i: IndexInfo = {
name: "idx", schema: "public", table: "users", definition: "CREATE INDEX ...",
is_unique: true, method: "btree", columns: ["id"], size_bytes: 4096, tablespace: null,
};
expectTypeOf(i).toMatchTypeOf<IndexInfo>();
});
it("ConstraintInfo has the documented fields", () => {
const c: ConstraintInfo = {
name: "ck", schema: "public", table: "users", contype: "CHECK",
definition: "CHECK (x > 0)", deferrable: false, validated: true, columns: ["x"],
};
expectTypeOf(c).toMatchTypeOf<ConstraintInfo>();
});
it("RecentConnection pairs a connection id with an opened_at timestamp", () => {
const r: RecentConnection = { connection_id: "c1", opened_at: "2026-08-02T00:00:00Z" };
expectTypeOf(r.connection_id).toEqualTypeOf<string>();
});
it("Connection carries favorite", () => {
const c = { favorite: true } as Connection;
expectTypeOf(c.favorite).toEqualTypeOf<boolean>();
});
});
+33 -1
View File
@@ -44,6 +44,8 @@ export interface Connection {
ssl_key_path?: string | null; ssl_key_path?: string | null;
// Environment label (production, staging, development, etc.) // Environment label (production, staging, development, etc.)
environment?: string | null; environment?: string | null;
// Favorite flag (v0.5.0 — pinned connection)
favorite: boolean;
} }
export type NewConnectionMode = "simple" | "detailed"; export type NewConnectionMode = "simple" | "detailed";
@@ -131,7 +133,7 @@ export interface ImportResult {
export interface TableInfo { export interface TableInfo {
name: string; name: string;
schema: string; schema: string;
table_type: "TABLE" | "VIEW"; table_type: "TABLE" | "VIEW" | "MATERIALIZED VIEW";
columns?: ColumnInfo[]; columns?: ColumnInfo[];
} }
@@ -143,6 +145,36 @@ export interface ColumnInfo {
is_fk: boolean; is_fk: boolean;
fk_ref: [string, string] | null; fk_ref: [string, string] | null;
default_value: string | null; default_value: string | null;
editable: boolean;
is_generated: boolean;
}
export interface IndexInfo {
name: string;
schema: string;
table: string;
definition: string;
is_unique: boolean;
method: string;
columns: string[];
size_bytes: number | null;
tablespace: string | null;
}
export interface ConstraintInfo {
name: string;
schema: string;
table: string;
contype: "CHECK" | "UNIQUE" | "EXCLUSION";
definition: string;
deferrable: boolean;
validated: boolean;
columns: string[];
}
export interface RecentConnection {
connection_id: string;
opened_at: string;
} }
export interface QueryResult { export interface QueryResult {
+1
View File
@@ -23,6 +23,7 @@ const makeConnection = (over: Partial<Connection> = {}): Connection => ({
keychain_ref: null, keychain_ref: null,
tag_ids: [], tag_ids: [],
environment: null, environment: null,
favorite: false,
created_at: "2026-07-26T00:00:00Z", created_at: "2026-07-26T00:00:00Z",
updated_at: "2026-07-26T00:00:00Z", updated_at: "2026-07-26T00:00:00Z",
...over, ...over,
+8
View File
@@ -0,0 +1,8 @@
import { describe, it, expect } from "vitest";
import pkg from "../../package.json";
describe("version", () => {
it("declares v0.5.0 across the app shell", () => {
expect(pkg.version).toBe("0.5.0");
});
});
+76 -1
View File
@@ -8,7 +8,7 @@ vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() }));
const makeConn = (over: Partial<Connection> = {}): Connection => ({ const makeConn = (over: Partial<Connection> = {}): Connection => ({
id: "c1", name: "P", db_type: "postgresql", host: "h", port: 5432, id: "c1", name: "P", db_type: "postgresql", host: "h", port: 5432,
username: null, folder_id: null, keychain_ref: null, tag_ids: [], username: null, folder_id: null, keychain_ref: null, tag_ids: [],
created_at: "", updated_at: "", ...over, favorite: false, created_at: "", updated_at: "", ...over,
}); });
beforeEach(() => { beforeEach(() => {
@@ -82,6 +82,7 @@ describe("moveConnection", () => {
ssl_cert_path: null, ssl_cert_path: null,
ssl_key_path: null, ssl_key_path: null,
tag_ids: [], tag_ids: [],
favorite: false,
created_at: "2024-01-01", created_at: "2024-01-01",
updated_at: "2024-01-01", updated_at: "2024-01-01",
}; };
@@ -189,3 +190,77 @@ describe("moveConnection", () => {
expect(callCount).toBe(2); expect(callCount).toBe(2);
}); });
}); });
describe("favorites / recents / move-selection", () => {
it("toggleFavorite optimistically flips favorite and persists", async () => {
useConnectionStore.setState({ connections: [makeConn({ id: "c1", favorite: false })] });
vi.spyOn(commands, "setConnectionFavorite").mockResolvedValue(undefined);
await useConnectionStore.getState().toggleFavorite("c1");
expect(useConnectionStore.getState().connections[0].favorite).toBe(true);
expect(commands.setConnectionFavorite).toHaveBeenCalledWith("c1", true);
});
it("toggleFavorite rolls back on failure", async () => {
useConnectionStore.setState({ connections: [makeConn({ id: "c1", favorite: false })] });
vi.spyOn(commands, "setConnectionFavorite").mockRejectedValue(new Error("boom"));
await expect(useConnectionStore.getState().toggleFavorite("c1")).rejects.toThrow("boom");
expect(useConnectionStore.getState().connections[0].favorite).toBe(false);
});
it("loadAll sorts favorites first within the returned list", async () => {
const fav = makeConn({ id: "a", name: "A", favorite: true });
const norm = makeConn({ id: "b", name: "B", favorite: false });
vi.spyOn(commands, "getConnections").mockResolvedValue([norm, fav]);
vi.spyOn(commands, "getFolders").mockResolvedValue([]);
vi.spyOn(commands, "getTags").mockResolvedValue([]);
vi.spyOn(commands, "getSettings").mockResolvedValue({} as any);
await useConnectionStore.getState().loadAll();
const ids = useConnectionStore.getState().connections.map((c) => c.id);
expect(ids[0]).toBe("a");
});
it("recordRecent calls the command once", async () => {
vi.spyOn(commands, "recordRecentConnection").mockResolvedValue(undefined);
await useConnectionStore.getState().recordRecent("c1");
expect(commands.recordRecentConnection).toHaveBeenCalledWith("c1");
});
describe("duplicateConnection", () => {
it("copies fields with a '(copy)' name and favorite false", async () => {
const src = makeConn({ id: "c1", name: "Prod", folder_id: "f1", tag_ids: ["t1"], favorite: true });
useConnectionStore.setState({ connections: [src] });
const created = { ...src, id: "c2", name: "Prod (copy)", favorite: false, keychain_ref: null };
vi.spyOn(commands, "createConnection").mockResolvedValue(created as any);
const out = await useConnectionStore.getState().duplicateConnection("c1");
expect(out.name).toBe("Prod (copy)");
expect(out.favorite).toBe(false);
expect(commands.createConnection).toHaveBeenCalledWith(expect.objectContaining({
name: "Prod (copy)",
host: src.host,
folder_id: "f1",
tag_ids: ["t1"],
password: null,
}));
});
it("throws when the connection is missing", async () => {
useConnectionStore.setState({ connections: [] });
await expect(useConnectionStore.getState().duplicateConnection("nope")).rejects.toThrow("not found");
});
});
it("moveSelectionToFolder moves connections and reparents folders", async () => {
useConnectionStore.setState({
connections: [makeConn({ id: "c1", folder_id: null })],
folders: [{ id: "f1", name: "f", parent_id: null, tag_ids: [], created_at: "", updated_at: "" }],
});
const originalMoveConnection = useConnectionStore.getState().moveConnection;
vi.spyOn(useConnectionStore.getState(), "moveConnection").mockResolvedValue(undefined);
vi.spyOn(commands, "updateFolder").mockResolvedValue({} as any);
await useConnectionStore.getState().moveSelectionToFolder(["c1", "f1"], "target");
expect(useConnectionStore.getState().connections[0].folder_id).toBe("target");
expect(useConnectionStore.getState().folders[0].parent_id).toBe("target");
// Restore the real action so the mock doesn't linger on future state objects
useConnectionStore.setState({ moveConnection: originalMoveConnection });
});
});
+90 -2
View File
@@ -5,12 +5,17 @@ import * as cmd from "../lib/commands";
interface ConnectionState { interface ConnectionState {
connections: Connection[]; folders: Folder[]; tags: Tag[]; connections: Connection[]; folders: Folder[]; tags: Tag[];
tagOrder: string[]; tagOrder: string[];
recent: Connection[];
loading: boolean; error: string | null; loading: boolean; error: string | null;
loadAll: () => Promise<void>; loadAll: () => Promise<void>;
loadRecent: () => Promise<void>;
recordRecent: (id: string) => Promise<void>;
toggleFavorite: (id: string) => Promise<void>;
loadTagOrder: () => Promise<void>; loadTagOrder: () => Promise<void>;
setTagOrder: (order: string[]) => Promise<void>; setTagOrder: (order: string[]) => Promise<void>;
createConnection: (input: ConnectionInput) => Promise<void>; createConnection: (input: ConnectionInput) => Promise<Connection>;
deleteConnection: (id: string) => Promise<void>; deleteConnection: (id: string) => Promise<void>;
duplicateConnection: (id: string) => Promise<Connection>;
createFolder: (input: FolderInput) => Promise<void>; createFolder: (input: FolderInput) => Promise<void>;
updateFolder: (id: string, input: FolderInput) => Promise<void>; updateFolder: (id: string, input: FolderInput) => Promise<void>;
deleteFolder: (id: string) => Promise<void>; deleteFolder: (id: string) => Promise<void>;
@@ -19,23 +24,48 @@ interface ConnectionState {
deleteTag: (id: string) => Promise<void>; deleteTag: (id: string) => Promise<void>;
addTagToItems: (tagId: string, folderIds: string[], connectionIds: string[]) => Promise<void>; addTagToItems: (tagId: string, folderIds: string[], connectionIds: string[]) => Promise<void>;
moveConnection: (connectionId: string, newFolderId: string | null) => Promise<void>; moveConnection: (connectionId: string, newFolderId: string | null) => Promise<void>;
moveSelectionToFolder: (selectedIds: string[], targetFolderId: string | null) => Promise<void>;
cachePassword: (connectionId: string, password: string) => Promise<void>; cachePassword: (connectionId: string, password: string) => Promise<void>;
getConnectionPassword: (connectionId: string) => Promise<string | null>; getConnectionPassword: (connectionId: string) => Promise<string | null>;
} }
export const useConnectionStore = create<ConnectionState>((set, get) => ({ export const useConnectionStore = create<ConnectionState>((set, get) => ({
connections: [], folders: [], tags: [], tagOrder: [], loading: false, error: null, connections: [], folders: [], tags: [], tagOrder: [], recent: [], loading: false, error: null,
loadAll: async () => { loadAll: async () => {
set({ loading: true, error: null }); set({ loading: true, error: null });
try { try {
const [connections, folders, tags] = await Promise.all([cmd.getConnections(), cmd.getFolders(), cmd.getTags()]); const [connections, folders, tags] = await Promise.all([cmd.getConnections(), cmd.getFolders(), cmd.getTags()]);
set({ connections, folders, tags, loading: false }); set({ connections, folders, tags, loading: false });
// Sort favorites first (stable sort preserves name order within groups)
set((s) => ({ connections: [...s.connections].sort((a, b) => Number(b.favorite) - Number(a.favorite)) }));
// Also load tag order // Also load tag order
get().loadTagOrder(); get().loadTagOrder();
} catch (e) { } catch (e) {
set({ loading: false, error: e instanceof Error ? e.message : String(e) }); set({ loading: false, error: e instanceof Error ? e.message : String(e) });
} }
}, },
loadRecent: async () => {
try {
const recent = await cmd.getRecentConnections(8);
const map = new Map(get().connections.map((c) => [c.id, c]));
set({ recent: recent.map((r) => map.get(r.connection_id)).filter(Boolean) as Connection[] });
} catch { /* best-effort: recent list is non-critical */ }
},
recordRecent: async (id) => {
try { await cmd.recordRecentConnection(id); } catch { /* best-effort */ }
},
toggleFavorite: async (id) => {
const prev = get().connections;
const next = prev.map((c) => c.id === id ? { ...c, favorite: !c.favorite } : c);
set({ connections: next });
const conn = next.find((c) => c.id === id);
try {
await cmd.setConnectionFavorite(id, conn?.favorite ?? false);
} catch (e) {
set({ connections: prev }); // rollback
throw e;
}
},
loadTagOrder: async () => { loadTagOrder: async () => {
try { try {
const settings = await cmd.getSettings(); const settings = await cmd.getSettings();
@@ -66,6 +96,36 @@ export const useConnectionStore = create<ConnectionState>((set, get) => ({
await cmd.saveConnectionSshPassphrase(conn.id, input.ssh_passphrase); await cmd.saveConnectionSshPassphrase(conn.id, input.ssh_passphrase);
} }
set((s) => ({ connections: [...s.connections, conn] })); set((s) => ({ connections: [...s.connections, conn] }));
return conn;
},
duplicateConnection: async (id) => {
const source = get().connections.find((c) => c.id === id);
if (!source) throw new Error("Connection not found");
// Passwords live in the OS keychain and are NEVER copied; the duplicate
// starts unkeyed and with no favorite flag.
const input: ConnectionInput = {
name: `${source.name} (copy)`,
db_type: source.db_type,
host: source.host,
port: source.port,
username: source.username ?? null,
database: source.database ?? null,
folder_id: source.folder_id,
tag_ids: source.tag_ids ?? [],
environment: source.environment ?? null,
ssh_host: source.ssh_host ?? null,
ssh_port: source.ssh_port ?? null,
ssh_user: source.ssh_user ?? null,
ssh_auth_method: (source.ssh_auth_method as ConnectionInput["ssh_auth_method"]) ?? null,
ssh_private_key_path: source.ssh_private_key_path ?? null,
ssl_mode: (source.ssl_mode as ConnectionInput["ssl_mode"]) ?? null,
ssl_ca_path: source.ssl_ca_path ?? null,
ssl_cert_path: source.ssl_cert_path ?? null,
ssl_key_path: source.ssl_key_path ?? null,
password: null,
use_keychain: false,
};
return get().createConnection(input);
}, },
deleteConnection: async (id) => { deleteConnection: async (id) => {
await cmd.deleteConnection(id); await cmd.deleteConnection(id);
@@ -173,4 +233,32 @@ export const useConnectionStore = create<ConnectionState>((set, get) => ({
throw e; throw e;
} }
}, },
moveSelectionToFolder: async (selectedIds, targetFolderId) => {
const prev = { connections: [...get().connections], folders: [...get().folders] };
const connIds = new Set<string>();
const folderIds = new Set<string>();
for (const id of selectedIds) {
if (prev.connections.some((c) => c.id === id)) connIds.add(id);
else if (prev.folders.some((f) => f.id === id)) folderIds.add(id);
}
try {
// Persist first: moveConnection has its own optimistic logic + rollback and
// would no-op (early-return on same folder_id) if we pre-set the target.
for (const id of connIds) {
await get().moveConnection(id, targetFolderId);
}
for (const id of folderIds) {
const f = prev.folders.find((x) => x.id === id);
if (f) await cmd.updateFolder(id, { name: f.name, parent_id: targetFolderId, tag_ids: f.tag_ids });
}
} catch (e) {
set({ connections: prev.connections, folders: prev.folders });
throw e;
}
// Apply the move optimistically at the end so state always ends moved
set((s) => ({
connections: s.connections.map((c) => connIds.has(c.id) ? { ...c, folder_id: targetFolderId } : c),
folders: s.folders.map((f) => folderIds.has(f.id) ? { ...f, parent_id: targetFolderId } : f),
}));
},
})); }));
+71 -1
View File
@@ -109,7 +109,7 @@ describe("dbViewerStore", () => {
store.setTabLoading(tabId, true); store.setTabLoading(tabId, true);
const mockData: QueryResult = { const mockData: QueryResult = {
columns: [{name:"id",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null},{name:"name",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null}], columns: [{name:"id",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null,editable:true,is_generated:false},{name:"name",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null,editable:true,is_generated:false}],
rows: [[1, "Alice"]], rows: [[1, "Alice"]],
total_rows: 1, page: 1, page_size: 50, total_rows: 1, page: 1, page_size: 50,
}; };
@@ -231,6 +231,76 @@ describe("dbViewerStore", () => {
expect(state.schemas).toEqual(["public", "private"]); expect(state.schemas).toEqual(["public", "private"]);
expect(state.tables).toEqual(tables); expect(state.tables).toEqual(tables);
}); });
it("stageCellEdit appends an update QueueItem with primaryKey + old/new data", () => {
const store = useDbViewerStore.getState();
store.openTab("public", "users");
const tabId = useDbViewerStore.getState().activeTabId!;
store.stageCellEdit({
tabId,
schema: "public",
table: "users",
primaryKey: { id: 1 },
oldData: { name: "Alice" },
newData: { name: "Alicia" },
description: "Edit users.name",
});
const q = useDbViewerStore.getState().changesQueue;
expect(q).toHaveLength(1);
expect(q[0].type).toBe("update");
expect(q[0].primaryKey).toEqual({ id: 1 });
expect(q[0].oldData).toEqual({ name: "Alice" });
expect(q[0].newData).toEqual({ name: "Alicia" });
});
it("re-staging the same cell replaces the pending entry (keeps original oldData)", () => {
const store = useDbViewerStore.getState();
store.openTab("public", "users");
const tabId = useDbViewerStore.getState().activeTabId!;
store.stageCellEdit({
tabId, schema: "public", table: "users", primaryKey: { id: 1 },
oldData: { name: "Alice" }, newData: { name: "Alicia" },
description: "Edit users.name",
});
store.stageCellEdit({
tabId, schema: "public", table: "users", primaryKey: { id: 1 },
oldData: { name: "Alice" }, newData: { name: "Alicia 2" },
description: "Edit users.name",
});
const q = useDbViewerStore.getState().changesQueue;
expect(q).toHaveLength(1);
expect(q[0].newData).toEqual({ name: "Alicia 2" });
expect(q[0].oldData).toEqual({ name: "Alice" }); // original DB value preserved
});
it("staging a different cell appends a second entry", () => {
const store = useDbViewerStore.getState();
store.openTab("public", "users");
const tabId = useDbViewerStore.getState().activeTabId!;
store.stageCellEdit({
tabId, schema: "public", table: "users", primaryKey: { id: 1 },
oldData: { name: "Alice" }, newData: { name: "Alicia" },
});
store.stageCellEdit({
tabId, schema: "public", table: "users", primaryKey: { id: 1 },
oldData: { age: 30 }, newData: { age: 31 },
});
expect(useDbViewerStore.getState().changesQueue).toHaveLength(2);
});
it("setIndexes / setConstraints update store slices", () => {
const store = useDbViewerStore.getState();
store.setIndexes([{ name: "idx", schema: "public", table: "t", definition: "", is_unique: true, method: "btree", columns: ["id"], size_bytes: 1, tablespace: null }]);
store.setConstraints([{ name: "ck", schema: "public", table: "t", contype: "CHECK", definition: "", deferrable: false, validated: true, columns: ["x"] }]);
expect(useDbViewerStore.getState().indexes).toHaveLength(1);
expect(useDbViewerStore.getState().constraints).toHaveLength(1);
});
it("reset clears indexes and constraints", () => {
useDbViewerStore.getState().setIndexes([{ name: "x", schema: "s", table: "t", definition: "", is_unique: false, method: "btree", columns: [], size_bytes: null, tablespace: null }]);
useDbViewerStore.getState().reset();
expect(useDbViewerStore.getState().indexes).toBeNull();
});
}); });
describe("refreshTree", () => { describe("refreshTree", () => {
+58 -1
View File
@@ -1,5 +1,5 @@
import { create } from "zustand"; import { create } from "zustand";
import type { QueryResult, TableInfo, ChangeItemType, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo } from "../lib/types"; import type { QueryResult, TableInfo, ChangeItemType, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, IndexInfo, ConstraintInfo } from "../lib/types";
import { getDatabases, getSchemas, getTables } from "../lib/commands"; import { getDatabases, getSchemas, getTables } from "../lib/commands";
// ─── Local types ──────────────────────────────────────────────── // ─── Local types ────────────────────────────────────────────────
@@ -96,6 +96,8 @@ interface DbViewerState {
sequences: SequenceInfo[] | null; sequences: SequenceInfo[] | null;
enums: EnumInfo[] | null; enums: EnumInfo[] | null;
extensions: ExtensionInfo[] | null; extensions: ExtensionInfo[] | null;
indexes: IndexInfo[] | null;
constraints: ConstraintInfo[] | null;
// Actions // Actions
openTab: (schema: string, table: string, forceNew?: boolean) => void; openTab: (schema: string, table: string, forceNew?: boolean) => void;
@@ -141,6 +143,17 @@ interface DbViewerState {
setSequences: (sequences: SequenceInfo[]) => void; setSequences: (sequences: SequenceInfo[]) => void;
setEnums: (enums: EnumInfo[]) => void; setEnums: (enums: EnumInfo[]) => void;
setExtensions: (extensions: ExtensionInfo[]) => void; setExtensions: (extensions: ExtensionInfo[]) => void;
setIndexes: (indexes: IndexInfo[]) => void;
setConstraints: (constraints: ConstraintInfo[]) => void;
stageCellEdit: (input: {
tabId: string;
schema: string;
table: string;
primaryKey: Record<string, unknown>;
oldData: Record<string, unknown>;
newData: Record<string, unknown>;
description?: string;
}) => void;
populate: ( populate: (
databases: string[], databases: string[],
schemas: string[], schemas: string[],
@@ -168,6 +181,8 @@ const initialState = {
sequences: null as SequenceInfo[] | null, sequences: null as SequenceInfo[] | null,
enums: null as EnumInfo[] | null, enums: null as EnumInfo[] | null,
extensions: null as ExtensionInfo[] | null, extensions: null as ExtensionInfo[] | null,
indexes: null as IndexInfo[] | null,
constraints: null as ConstraintInfo[] | null,
}; };
// ─── Store ────────────────────────────────────────────────────── // ─── Store ──────────────────────────────────────────────────────
@@ -400,6 +415,48 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
setSequences: (sequences) => set({ sequences }), setSequences: (sequences) => set({ sequences }),
setEnums: (enums) => set({ enums }), setEnums: (enums) => set({ enums }),
setExtensions: (extensions) => set({ extensions }), setExtensions: (extensions) => set({ extensions }),
setIndexes: (indexes) => set({ indexes }),
setConstraints: (constraints) => set({ constraints }),
stageCellEdit: (input) => {
// Re-staging the same cell replaces the existing pending entry (keeps the
// original oldData so revert restores the DB value) instead of stacking
// a second queue item.
const colName = Object.keys(input.newData)[0];
const existing = get().changesQueue.find(
(c) =>
c.status === "pending" &&
c.type === "update" &&
c.schema === input.schema &&
c.table === input.table &&
c.primaryKey &&
JSON.stringify(c.primaryKey) === JSON.stringify(input.primaryKey) &&
Object.keys(c.newData ?? {})[0] === colName,
);
if (existing) {
set((state) => ({
changesQueue: state.changesQueue.map((c) =>
c.id === existing.id
? {
...c,
newData: input.newData,
description: input.description ?? c.description,
}
: c,
),
}));
return;
}
get().addChange({
type: "update",
schema: input.schema,
table: input.table,
primaryKey: input.primaryKey,
oldData: input.oldData,
newData: input.newData,
description: input.description ?? `Edit ${input.table}`,
});
},
populate: (databases, schemas, tables) => populate: (databases, schemas, tables) =>
set({ databases, schemas, tables }), set({ databases, schemas, tables }),