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) |
| 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 |
| 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 |
| Import/Export connections (JSON) | ✅ | Bulk import with validation, skipped-record reporting |
| 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) |
| Inline tag creation | ✅ | "Create first tag" inline form (name + color) in SearchableTagPicker empty state |
| Move-to-folder bulk action | | |
| Favorites / Recent connections | | |
| Connection status indicator on cards | | |
| Move-to-folder bulk action | | Selection toolbar → Move to Folder dialog (folder picker, move confirmed via dialog) |
| 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 | | 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
| 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 |
| Data grid pagination | ✅ | Page nav, page size selector persisted in settings |
| Column filtering (server-side) | ✅ | eq, neq, contains, starts, ends, gt, lt, null, notnull pushed to SQL WHERE |
| Visual filter builder | ✅ | Drag-and-drop column palette (@dnd-kit) with type-aware operators (textish → contains, else eq), AND semantics, persists in tab `filterRules` |
| Column sorting (server-side) | ✅ | Multi-column asc/desc pushed to SQL ORDER BY |
| Column show/hide | ✅ | Toggle visibility per column |
| Column resize (drag handle) | ✅ | Double-click to auto-fit |
@@ -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) |
| Edit connection modal (from DB viewer) | ✅ | AnimatedModal with keychain password fetch on test |
| Connection drop banner | ✅ | Auto-detects broken connections with reconnect prompt |
| Inline cell editing | | 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 |
| Row detail / expandable row view | | |
| Keyboard cell navigation (arrow keys, Tab) | | |
| Cell-level copy (right-click or Ctrl+C) | | Only bulk copy via toolbar |
| Row detail / expandable row view | | RowDetailDrawer: right-drawer per row, opened via the cell context menu **View Row** |
| Keyboard cell navigation (arrow keys, Tab) | | Arrow keys + Tab/Shift+Tab wrap (`keyboardNav`); Enter opens the editor; **Esc cancels editing even after the editor lost focus** (document-level listener) and closes the context menu |
| 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)
| 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. |
| 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). |
| Indexes (per table) | | |
| Constraints (CHECK, UNIQUE beyond PK/FK) | | |
| Materialized views | | Not distinguished from regular views |
| Stored procedures | 🟡 | Included in Functions via p.prokind IN ('f','p'); no separate view yet |
| Indexes (per table) | | Per-table index list with columns, method, unique/partial flags (via pg_indexes) |
| Constraints (CHECK, UNIQUE beyond PK/FK) | | CHECK/UNIQUE constraints beyond PK/FK, introspected via information_schema |
| Materialized views | | Distinct icon in table tree, browsable, read-only (via pg_matviews) |
| 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. |
### 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** |
| DB-to-DB sync | ❌ | ❌ | **Built-in pipe sync** |
| 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** |
| 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)** |
| OS credential vault | ✅ | ✅ | **Keychain / Secret Service** |
| 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) |
| Query history | ✅ (auto-saved) | ✅ | **✅ Toolbar dropdown, favorites, Queries view** |
| 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
- **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
- **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`
### 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
- **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
- **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
- **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
- **Server-Side Filtering & Sorting** — filters and sorts pushed to SQL WHERE/ORDER BY
- **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
- **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
- **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)
- **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)
### 🟡 In Progress / Upcoming
- **Inline Cell Editing** — Edit cells directly in the data grid
- **Visual Filter Builder** — drag-and-drop filter construction
- **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.
### 🔮 Future
- **Multi-DB Support** — MySQL browsing, Redis key browser, full MySQL/SQLite/Redis parity with PostgreSQL
- **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
- **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",
"private": true,
"version": "0.2.0",
"version": "0.5.0",
"description": "An open-source, high-performance database GUI client for PostgreSQL and beyond",
"type": "module",
"scripts": {
+1 -1
View File
@@ -1783,7 +1783,7 @@ dependencies = [
[[package]]
name = "gridline"
version = "0.2.0"
version = "0.5.0"
dependencies = [
"chrono",
"deadpool-postgres",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "gridline"
version = "0.2.0"
version = "0.5.0"
description = "An open-source, high-performance database GUI client for PostgreSQL and beyond"
authors = ["you"]
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)
}
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)]
mod tests {
use super::*;
@@ -232,4 +292,70 @@ mod tests {
delete_connection_inner(&st, &conn.id).unwrap();
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::models::db_viewer::{
Change, ColumnInfo, EnumInfo, ExtensionInfo, FunctionInfo, QueryResult,
SequenceInfo, TableInfo, TriggerInfo,
Change, ColumnInfo, ConstraintInfo, EnumInfo, ExtensionInfo, FunctionInfo,
IndexInfo, QueryResult, SequenceInfo, TableInfo, TriggerInfo,
};
use std::collections::HashMap;
use tauri::State;
@@ -90,6 +90,40 @@ pub fn offset(page: i64, page_size: i64) -> i64 {
(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
// ---------------------------------------------------------------------------
@@ -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.
///
/// The returned SQL uses `?` placeholders for both the SET values and the
@@ -289,7 +383,10 @@ pub fn build_update_sql(
table: &str,
primary_key: &[(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
.iter()
.map(|(col, _)| format!("\"{}\" = ?", col))
@@ -298,13 +395,22 @@ pub fn build_update_sql(
.iter()
.map(|(col, _)| format!("\"{}\" = ?", col))
.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!(
"UPDATE \"{}\".\"{}\" SET {} WHERE {}",
schema,
table,
set_clause.join(", "),
where_clause.join(" AND ")
)
),
params,
))
}
/// Build a parameterized DELETE SQL statement.
@@ -317,17 +423,23 @@ pub fn build_delete_sql(
schema: &str,
table: &str,
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
.iter()
.map(|(col, _)| format!("\"{}\" = ?", col))
.collect();
Ok((
format!(
"DELETE FROM \"{}\".\"{}\" WHERE {}",
schema,
table,
where_clause.join(" AND ")
)
),
primary_key.iter().map(|(_, v)| v.clone()).collect(),
))
}
/// Build a parameterized INSERT SQL statement.
@@ -367,7 +479,10 @@ pub fn build_pg_update_sql(
table: &str,
primary_key: &[(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 set_clause: Vec<String> = new_data
.iter()
@@ -383,7 +498,7 @@ pub fn build_pg_update_sql(
format!("\"{}\" = ${}", col, params.len())
})
.collect();
(
Ok((
format!(
"UPDATE \"{}\".\"{}\" SET {} WHERE {}",
schema,
@@ -392,7 +507,7 @@ pub fn build_pg_update_sql(
where_clause.join(" AND ")
),
params,
)
))
}
/// Build a PostgreSQL DELETE statement.
@@ -400,7 +515,10 @@ pub fn build_pg_delete_sql(
schema: &str,
table: &str,
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 where_clause: Vec<String> = primary_key
.iter()
@@ -409,7 +527,7 @@ pub fn build_pg_delete_sql(
format!("\"{}\" = ${}", col, params.len())
})
.collect();
(
Ok((
format!(
"DELETE FROM \"{}\".\"{}\" WHERE {}",
schema,
@@ -417,7 +535,7 @@ pub fn build_pg_delete_sql(
where_clause.join(" AND ")
),
params,
)
))
}
/// 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 {
// Integer types
if let Ok(Some(v)) = row.try_get::<_, Option<i32>>(i) {
return serde_json::json!(v);
}
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) {
return serde_json::json!(v);
@@ -1024,7 +1149,9 @@ pub async fn get_table_data(
COALESCE(fk.is_fk, false) AS is_fk,
fk.foreign_table_name,
fk.foreign_column_name,
c.column_default
c.column_default,
a.attgenerated,
a.attidentity
FROM information_schema.columns c
LEFT JOIN (
SELECT ku.column_name, true AS is_pk
@@ -1056,6 +1183,11 @@ LEFT JOIN (
AND tc.table_schema = $1
AND tc.table_name = $2
) 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
ORDER BY c.ordinal_position"#;
let col_rows = client
@@ -1068,11 +1200,21 @@ ORDER BY c.ordinal_position"#;
let is_fk: bool = r.get(4);
let fk_table: Option<String> = r.get(5);
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 {
name: r.get(0),
data_type: r.get(1),
is_nullable: r.get::<_, String>(2) == "YES",
is_pk: r.get(3),
is_pk,
is_fk,
fk_ref: if is_fk {
Some((fk_table.unwrap_or_default(), fk_column.unwrap_or_default()))
@@ -1080,6 +1222,8 @@ ORDER BY c.ordinal_position"#;
None
},
default_value: r.get::<_, Option<String>>(7),
editable: editable_from_att(&attgenerated, &attidentity) && !is_pk,
is_generated: !attgenerated.is_empty(),
}
})
.collect();
@@ -1098,7 +1242,8 @@ ORDER BY c.ordinal_position"#;
"timestamp without time zone", "timestamp 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()
.map(|c| {
let lower = c.data_type.to_lowercase();
@@ -1110,10 +1255,12 @@ ORDER BY c.ordinal_position"#;
}
})
.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!(
"SELECT {} FROM \"{}\".\"{}\" WHERE 1=1{} {} LIMIT {} OFFSET {}",
select_cols.join(", "),
schema, table, filter_clause, order_clause, ps, off
"{} WHERE 1=1{} {} LIMIT {} OFFSET {}",
build_pg_select_from_items(&schema, &table, select_items, has_pk),
filter_clause, order_clause, ps, off
);
let data_rows = if filter_params.is_empty() {
client
@@ -1207,14 +1354,21 @@ ORDER BY c.ordinal_position"#;
is_fk: fk.is_some(),
fk_ref: fk.map(|(t, c)| (t.clone(), c.clone())),
default_value: default_val.clone(),
editable: !*is_pk,
is_generated: false,
}
})
.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!(
"SELECT * FROM \"{}\".\"{}\" WHERE 1=1{} {} LIMIT {} OFFSET {}",
schema, table, filter_clause, order_clause, ps, off
"{} WHERE 1=1{} {} LIMIT {} OFFSET {}",
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 col_count = stmt.column_count();
@@ -1334,6 +1488,8 @@ ORDER BY c.ordinal_position"#;
None
},
default_value: r.get::<_, Option<String>>(7),
editable: true,
is_generated: false,
}
})
.collect();
@@ -1411,6 +1567,8 @@ ORDER BY c.ordinal_position"#;
is_fk: fk.is_some(),
fk_ref: fk.map(|(t, c)| (t.clone(), c.clone())),
default_value: default_val.clone(),
editable: true,
is_generated: false,
}
})
.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]
pub async fn execute_change(
connection_id: String,
@@ -1467,7 +1635,7 @@ pub async fn execute_change(
} => {
let pk = parse_json_pairs(primary_key)?;
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 {
schema,
@@ -1485,7 +1653,7 @@ pub async fn execute_change(
..
} => {
let pk = parse_json_pairs(primary_key)?;
build_pg_delete_sql(schema, table, &pk)
build_pg_delete_sql(schema, table, &pk)?
}
Change::AlterTable { sql, .. } => {
// Execute the raw DDL directly; no bound parameters.
@@ -1536,7 +1704,13 @@ pub async fn execute_change(
r
})
.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(())
}
Some(crate::db::pool::DbHandle::Sqlite(conn)) => {
@@ -1550,13 +1724,7 @@ pub async fn execute_change(
} => {
let pk = parse_json_pairs(primary_key)?;
let data = parse_json_pairs(new_data)?;
(
build_update_sql(schema, table, &pk, &data),
pk.iter()
.chain(data.iter())
.map(|(_, v)| v.clone())
.collect(),
)
build_update_sql(schema, table, &pk, &data)?
}
Change::Insert {
schema,
@@ -1579,10 +1747,7 @@ pub async fn execute_change(
..
} => {
let pk = parse_json_pairs(primary_key)?;
(
build_delete_sql(schema, table, &pk),
pk.iter().map(|(_, v)| v.clone()).collect(),
)
build_delete_sql(schema, table, &pk)?
}
Change::AlterTable { sql, .. } => {
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> =
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())?;
if let Some(msg) = affected_count_error(n as u64) {
return Err(msg);
}
Ok(())
}
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]
pub async fn get_triggers(
connection_id: String,
@@ -1877,6 +2125,28 @@ mod tests {
use super::*;
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.
#[test]
fn pagination_offset_is_correct() {
@@ -1930,7 +2200,7 @@ mod tests {
("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!(
sql.to_uppercase().contains("UPDATE"),
@@ -1955,7 +2225,7 @@ mod tests {
fn build_change_delete_sql_is_valid() {
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!(
sql.to_uppercase().contains("DELETE FROM"),
@@ -2116,4 +2386,90 @@ mod tests {
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,
fk_ref: None,
default_value: None,
editable: true,
is_generated: false,
})
.collect(),
None => {
@@ -309,6 +311,8 @@ async fn execute_pg_raw(
is_fk: false,
fk_ref: None,
default_value: None,
editable: true,
is_generated: false,
})
.collect();
saw_columns = true;
@@ -432,6 +436,8 @@ fn execute_sqlite_with_query(
is_fk: false,
fk_ref: None,
default_value: None,
editable: true,
is_generated: false,
}
})
.collect();
+139 -16
View File
@@ -13,6 +13,10 @@ pub struct TestConnectionResult {
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
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
@@ -212,6 +216,8 @@ pub async fn test_database_connection(
return TestConnectionResult {
ok: false,
error: Some(err),
server_version: None,
latency_ms: None,
};
}
@@ -223,12 +229,16 @@ pub async fn test_database_connection(
other => TestConnectionResult {
ok: false,
error: Some(format!("unsupported database type: {other}")),
server_version: None,
latency_ms: None,
},
};
TestConnectionResult {
ok: result.ok,
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 {
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
@@ -263,7 +280,12 @@ async fn test_pg_connection(config: &DbConfig, ssh: &SshManager) -> TestConnecti
Ok(t) => t,
Err(e) => {
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)
.connect_timeout(std::time::Duration::from_secs(10));
let start = std::time::Instant::now();
let result = match tls {
None => crate::commands::db_viewer::connect_pg_with(&pgconfig, tokio_postgres::NoTls).await,
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
}
};
let latency_ms = Some(start.elapsed().as_millis() as u64);
match result {
Ok((_client, _handle)) => {
Ok((client, _handle)) => {
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
// Spawn the connection handler so it keeps running while we test.
// (Already spawned inside `connect_pg_with`.)
TestConnectionResult { ok: true, error: None }
// Best-effort server version from the live client; None if the
// query fails. The driver task is already spawned inside
// `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) => {
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
TestConnectionResult {
ok: false,
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 {
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()
@@ -351,6 +395,7 @@ async fn test_mysql_connection(config: &DbConfig, ssh: &SshManager) -> TestConne
}
}
let start = std::time::Instant::now();
match MySqlPoolOptions::new()
.max_connections(1)
.acquire_timeout(std::time::Duration::from_secs(10))
@@ -359,14 +404,28 @@ async fn test_mysql_connection(config: &DbConfig, ssh: &SshManager) -> TestConne
{
Ok(pool) => {
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;
TestConnectionResult { ok: true, error: None }
TestConnectionResult {
ok: true,
error: None,
server_version,
latency_ms,
}
}
Err(e) => {
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
TestConnectionResult {
ok: false,
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
/// can be opened as a valid SQLite database.
fn test_sqlite_connection(config: &DbConfig) -> TestConnectionResult {
let start = std::time::Instant::now();
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 {
ok: false,
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 {
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();
@@ -406,6 +488,7 @@ async fn test_redis_connection(config: &DbConfig, ssh: &SshManager) -> TestConne
format!("redis://{}:{}/", target.host, target.port)
};
let start = std::time::Instant::now();
match redis::Client::open(conn_str.as_str()) {
Ok(client) => {
match timeout(
@@ -416,13 +499,20 @@ async fn test_redis_connection(config: &DbConfig, ssh: &SshManager) -> TestConne
{
Ok(Ok(_conn)) => {
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)) => {
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
TestConnectionResult {
ok: false,
error: Some(e.to_string()),
server_version: None,
latency_ms: None,
}
}
Err(_) => {
@@ -430,6 +520,8 @@ async fn test_redis_connection(config: &DbConfig, ssh: &SshManager) -> TestConne
TestConnectionResult {
ok: false,
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 {
ok: false,
error: Some(e.to_string()),
server_version: None,
latency_ms: None,
}
}
}
@@ -466,18 +560,47 @@ mod tests {
#[test]
fn test_connection_result_serialization() {
// ok=true result serializes correctly
let result = TestConnectionResult { ok: true, error: None };
// ok=true result with server_version/latency serializes all fields
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();
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 {
ok: false,
error: Some("connection refused".to_string()),
server_version: None,
latency_ms: None,
};
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 {
Some(s) => format!(
"SELECT table_name, table_type FROM information_schema.tables \
WHERE table_schema = '{}' ORDER BY table_name",
s
WHERE table_schema = '{}' \
UNION ALL \
SELECT matviewname AS table_name, 'MATERIALIZED VIEW' AS table_type \
FROM pg_matviews WHERE schemaname = '{}' \
ORDER BY table_name",
s, s
),
None => {
"SELECT table_name, table_type, table_schema FROM information_schema.tables \
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"
.to_string()
}
@@ -285,6 +292,61 @@ pub fn pg_extensions_query() -> 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
// ---------------------------------------------------------------------------
@@ -365,6 +427,49 @@ mod tests {
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
// ---------------------------------------------------------------
+6
View File
@@ -78,6 +78,10 @@ pub fn run() {
connections::update_connection,
connections::delete_connection,
connections::add_connection_tags,
connections::set_connection_favorite,
connections::record_recent_connection,
connections::get_recent_connections,
connections::clear_recent_connections,
folders::get_folders,
folders::create_folder,
folders::delete_folder,
@@ -107,6 +111,8 @@ pub fn run() {
db_viewer::get_sequences,
db_viewer::get_enums,
db_viewer::get_extensions,
db_viewer::get_indexes,
db_viewer::get_constraints,
keychain::save_connection_password,
keychain::get_connection_password,
keychain::delete_connection_password,
+18
View File
@@ -12,6 +12,7 @@ pub struct Connection {
pub folder_id: Option<String>,
pub keychain_ref: Option<String>,
pub environment: Option<String>,
pub favorite: bool,
pub ssh_host: Option<String>,
pub ssh_port: Option<i64>,
pub ssh_user: Option<String>,
@@ -118,6 +119,7 @@ mod tests {
folder_id: Some("folder".to_string()),
keychain_ref: Some("keychain-ref".to_string()),
environment: None,
favorite: false,
tag_ids: vec![],
created_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();
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 fk_ref: Option<(String, 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)]
@@ -338,12 +365,26 @@ mod tests {
is_fk: true,
fk_ref: Some(("users".to_string(), "id".to_string())),
default_value: None,
editable: true,
is_generated: false,
};
let json = serde_json::to_string(&col).unwrap();
assert!(json.contains("user_id"));
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]
fn pagination_serialization() {
let pagination = Pagination {
+2
View File
@@ -2,11 +2,13 @@ pub mod backup;
pub mod connection;
pub mod db_viewer;
pub mod folder;
pub mod recent;
pub mod ssh;
pub mod tag;
pub mod settings;
pub use connection::{Connection, ConnectionInput};
pub use recent::RecentConnection;
#[allow(unused_imports)]
pub use db_viewer::{Change, ColumnInfo, FilterRule, Pagination, QueryResult, SortRule, TableInfo};
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"),
];
/// 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> {
conn.execute_batch(
"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())?;
}
// 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(())
}
@@ -269,7 +310,7 @@ mod tests {
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM schema_version", [], |row| row.get(0))
.unwrap();
assert_eq!(count, 5);
assert_eq!(count, 6);
}
#[test]
@@ -408,13 +449,77 @@ mod tests {
fn v6_bumps_schema_version_to_6() {
let conn = Connection::open_in_memory().unwrap();
run_migrations(&conn).unwrap();
let ver: i64 = conn
let count: i64 = conn
.query_row(
"SELECT MAX(version) FROM schema_version",
"SELECT COUNT(*) FROM schema_version WHERE version = 6",
[],
|r| r.get(0),
)
.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 mut stmt = conn
.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())?;
let rows = stmt
@@ -273,9 +273,10 @@ impl Store {
ssl_cert_path: row.get(16)?,
ssl_key_path: row.get(17)?,
environment: row.get(18)?,
favorite: row.get(19)?,
tag_ids: vec![],
created_at: row.get(19)?,
updated_at: row.get(20)?,
created_at: row.get(20)?,
updated_at: row.get(21)?,
})
})
.map_err(|e| e.to_string())?;
@@ -320,6 +321,7 @@ impl Store {
database: input.database,
keychain_ref: None,
environment: input.environment,
favorite: false,
ssh_host: input.ssh_host,
ssh_port: input.ssh_port,
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> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
conn.execute("DELETE FROM connections WHERE id = ?1", params![id])
@@ -375,6 +435,7 @@ impl Store {
folder_id: input.folder_id,
keychain_ref: None,
environment: input.environment,
favorite: false,
ssh_host: input.ssh_host,
ssh_port: input.ssh_port,
ssh_user: input.ssh_user,
@@ -1282,6 +1343,52 @@ mod tests {
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 {
store
.create_connection(ConnectionInput {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Gridline",
"version": "0.2.0",
"version": "0.5.0",
"identifier": "com.adrianbonpin.gridline",
"build": {
"beforeDevCommand": "bun run dev",
@@ -13,7 +13,7 @@ const tags: Tag[] = [
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: ["t1", "t2"], created_at: "", updated_at: "",
tag_ids: ["t1", "t2"], favorite: false, created_at: "", updated_at: "",
};
function Wrapper({ children }: { children: React.ReactNode }) {
@@ -22,6 +22,7 @@ function Wrapper({ children }: { children: React.ReactNode }) {
describe("ConnectionCard", () => {
beforeEach(() => {
vi.clearAllMocks();
useUiStore.setState({ selectedItemIds: [] });
});
@@ -43,6 +44,24 @@ describe("ConnectionCard", () => {
render(<ConnectionCard connection={conn} tags={tags} />, { wrapper: Wrapper });
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", () => {
const sqlite = { ...conn, db_type: "sqlite" as const, host: "/data/x.db", port: null };
render(<ConnectionCard connection={sqlite} tags={tags} />, { wrapper: Wrapper });
@@ -74,4 +93,17 @@ describe("ConnectionCard", () => {
expect(fn).not.toHaveBeenCalled();
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 type { Connection, Tag } from "../../lib/types";
import type { Connection, ConnectionInput, Tag } from "../../lib/types";
import { DbIcon, DB_LABELS } from "../../lib/dbIcons";
import { ENV_LABELS, ENV_COLORS } from "../../lib/environment";
import { TagBadge } from "../tags/TagBadge";
import { ConnectionCardMenu } from "./ConnectionCardMenu";
import { Check, GripVertical } from "lucide-react";
import { useUiStore } from "../../stores/uiStore";
import { useDraggable } from "@dnd-kit/core";
@@ -13,6 +14,35 @@ interface ConnectionCardProps {
tags: Tag[];
onTagToggle?: (id: 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({
@@ -20,6 +50,9 @@ function ConnectionCardBase({
tags,
onTagToggle,
onOpenDbViewer,
onEdit,
onDuplicate,
onDelete,
}: ConnectionCardProps) {
const selectedItemIds = useUiStore((s) => s.selectedItemIds);
const toggleItemSelection = useUiStore((s) => s.toggleItemSelection);
@@ -68,7 +101,7 @@ function ConnectionCardBase({
<div
{...listeners}
{...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"
>
<GripVertical size={14} className="text-text-muted" />
@@ -107,6 +140,12 @@ function ConnectionCardBase({
))}
</div>
</div>
<ConnectionCardMenu
connection={connection}
onEdit={() => onEdit?.(connection)}
onDuplicate={() => onDuplicate?.(connection)}
onDelete={() => onDelete?.(connection)}
/>
<button
onClick={(e) => {
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 => ({
id, name: `Conn ${id}`, db_type: "postgresql", host: "h", port: 5432,
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[] = [
@@ -109,6 +109,9 @@ interface ConnectionGridProps {
onEditFolder?: (folder: Folder) => void;
onDeleteFolder?: (folder: Folder) => void;
onOpenDbViewer?: (connectionId: string) => void;
onEditConnection?: (conn: Connection) => void;
onDuplicateConnection?: (conn: Connection) => void;
onDeleteConnection?: (conn: Connection) => void;
}
export function ConnectionGrid({
@@ -122,6 +125,9 @@ export function ConnectionGrid({
onEditFolder,
onDeleteFolder,
onOpenDbViewer,
onEditConnection,
onDuplicateConnection,
onDeleteConnection,
}: ConnectionGridProps) {
const selectedItemIds = useUiStore((s) => s.selectedItemIds);
const toggleItemSelection = useUiStore((s) => s.toggleItemSelection);
@@ -272,6 +278,9 @@ export function ConnectionGrid({
tags={tags}
onTagToggle={onTagToggle}
onOpenDbViewer={onOpenDbViewer}
onEdit={onEditConnection}
onDuplicate={onDuplicateConnection}
onDelete={onDeleteConnection}
/>
))}
</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();
});
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 () => {
const user = userEvent.setup();
useDbViewerStore.getState().addChange({
@@ -156,6 +172,21 @@ describe("ChangesQueuePanel", () => {
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 () => {
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);
+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) {
return type.charAt(0).toUpperCase() + type.slice(1);
}
@@ -39,7 +55,7 @@ function tableRef(change: QueueItem): string {
return change.table ?? "-";
}
export function ChangesQueuePanel() {
export function ChangesQueuePanel({ onCommitted }: { onCommitted?: () => void } = {}) {
const changesQueue = useDbViewerStore((state) => state.changesQueue);
const removeChange = useDbViewerStore((state) => state.removeChange);
const clearChanges = useDbViewerStore((state) => state.clearChanges);
@@ -84,6 +100,7 @@ export function ChangesQueuePanel() {
}
if (committedCount > 0) {
onCommitted?.();
notify(`${committedCount} change(s) committed`, "success");
}
@@ -183,6 +200,17 @@ export function ChangesQueuePanel() {
<div className="mt-1 text-xs text-text-muted truncate">
{formatChangeLabel(change)}
</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>
))
) : (
@@ -1,7 +1,13 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
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 { useUiStore } from "../../stores/uiStore";
import * as commands from "../../lib/commands";
vi.mock("../../hooks/useDbConnection", () => ({
@@ -12,9 +18,17 @@ vi.mock("../../hooks/useDbConnection", () => ({
}));
vi.mock("@tanstack/react-virtual", () => ({
useVirtualizer: () => ({
getVirtualItems: () => [],
getTotalSize: () => 0,
useVirtualizer: ({ count }: any) => ({
getVirtualItems: () =>
count > 0
? Array.from({ length: count }, (_, i) => ({
key: i,
index: i,
start: i * 36,
size: 36,
}))
: [],
getTotalSize: () => count * 36,
measureElement: () => {},
}),
}));
@@ -64,6 +78,8 @@ const mockQueryResult = {
is_fk: false,
fk_ref: null,
default_value: null,
editable: true,
is_generated: false,
},
],
rows: [[1]],
@@ -99,6 +115,126 @@ describe("DbViewerScreen", () => {
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", () => {
render(
<DbViewerScreen
@@ -459,4 +595,230 @@ describe("DbViewerScreen", () => {
).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 { TabBar } from "./TabBar";
import { VirtualDataGrid } from "../grid/VirtualDataGrid";
import { RowDetailDrawer } from "../grid/RowDetailDrawer";
import { TableControls } from "./TableControls";
import { EditConnectionModal } from "./EditConnectionModal";
import { useDbConnection } from "../../hooks/useDbConnection";
@@ -29,6 +30,124 @@ import { SchemaVisualizerPage } from "./SchemaVisualizerPage";
import { QueriesPanel } from "../queries/QueriesPanel";
import { useQueryStore } from "../../stores/queryStore";
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 {
connectionId: string;
@@ -48,6 +167,7 @@ export function DbViewerScreen({
const [queriesPanelWidth, setQueriesPanelWidth] = useState(280);
const [searchQuery, setSearchQuery] = useState("");
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
const [rowDetailIdx, setRowDetailIdx] = useState<number | null>(null);
const [editModalOpen, setEditModalOpen] = useState(false);
const [destructiveQuery, setDestructiveQuery] = useState<string | null>(null);
const connections = useConnectionStore((s) => s.connections);
@@ -84,6 +204,19 @@ export function DbViewerScreen({
const filterRules = activeTab?.filterRules ?? [];
const sortRules = activeTab?.sortRules ?? [];
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 setTabError = useDbViewerStore((s) => s.setTabError);
@@ -96,6 +229,18 @@ export function DbViewerScreen({
const setCurrentSchema = useDbViewerStore((s) => s.setCurrentSchema);
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(
async (tab: NonNullable<typeof activeTab>) => {
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
// (which keeps the first onRun closure) always sees the latest query text.
const handleRunQuery = useCallback(() => {
@@ -243,6 +413,108 @@ export function DbViewerScreen({
fetchData(activeTab);
}, [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
useEffect(() => {
if (!activeTab) return;
@@ -630,9 +902,48 @@ const onQueriesPanelResizeStart = useCallback(
const activeTable = activeTab?.table ?? "";
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 (
<div className="flex-1 w-0 flex flex-col min-w-0 overflow-hidden">
<TabBar />
<TabBar onCommitted={handleCommitted} />
{!activeTab ? (
<div className="flex-1 flex flex-col items-center justify-center gap-2 text-text-muted">
{currentView === "queries" ? (
@@ -794,16 +1105,29 @@ const onQueriesPanelResizeStart = useCallback(
)
}
variant="query"
isMatview={isMatview}
/>
)}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<VirtualDataGrid
connectionId={connectionId}
schema={activeSchema}
table={activeTable}
rows={processedRows}
columns={columns}
hiddenColumns={hiddenColumns}
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) => {
setSelectedRows(
(prev) => {
@@ -934,16 +1258,29 @@ const onQueriesPanelResizeStart = useCallback(
onClearSelection={() =>
setSelectedRows(new Set())
}
isMatview={isMatview}
/>
)}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<VirtualDataGrid
connectionId={connectionId}
schema={activeSchema}
table={activeTable}
rows={processedRows}
columns={columns}
hiddenColumns={hiddenColumns}
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) => {
setSelectedRows((prev) => {
const next = new Set(
@@ -978,6 +1315,16 @@ const onQueriesPanelResizeStart = useCallback(
</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>
);
}
@@ -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 {
BookMarked,
ChevronRight,
FunctionSquare,
GitBranch,
ListChecks,
ListOrdered,
SquareFunction,
Tag,
Puzzle,
Search,
@@ -19,6 +22,8 @@ import type {
SequenceInfo,
EnumInfo,
ExtensionInfo,
IndexInfo,
ConstraintInfo,
} from "../../lib/types";
export type ObjectType =
@@ -26,7 +31,10 @@ export type ObjectType =
| "triggers"
| "sequences"
| "enums"
| "extensions";
| "extensions"
| "indexes"
| "constraints"
| "procedures";
interface ObjectExplorerPageProps {
connectionId: string;
@@ -38,6 +46,9 @@ const TYPE_LABELS: Record<ObjectType, string> = {
sequences: "Sequences",
enums: "Enums",
extensions: "Extensions",
indexes: "Indexes",
constraints: "Constraints",
procedures: "Procedures",
};
const OBJECT_TYPE_OPTIONS = (Object.keys(TYPE_LABELS) as ObjectType[]).map(
@@ -50,8 +61,21 @@ const SINGULAR_LABELS: Record<ObjectType, string> = {
sequences: "sequence",
enums: "enum",
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> = {
functions: (
<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" />,
enums: <Tag 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 =
@@ -67,7 +94,9 @@ type AnyObject =
| TriggerInfo
| SequenceInfo
| EnumInfo
| ExtensionInfo;
| ExtensionInfo
| IndexInfo
| ConstraintInfo;
/** Build a unique key per item. Functions use their signature to disambiguate overloads. */
function itemKey(item: AnyObject): string {
@@ -487,10 +516,7 @@ function SyntaxCode({
);
}
function renderDetail(type: ObjectType, item: AnyObject) {
switch (type) {
case "functions": {
const f = item as FunctionInfo;
function renderFunctionDetail(f: FunctionInfo) {
return (
<div>
<div className="border-b border-border px-4 py-2">
@@ -590,6 +616,191 @@ function renderDetail(type: ObjectType, item: AnyObject) {
)}
</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": {
const t = item as TriggerInfo;
@@ -867,10 +1078,15 @@ export function ObjectExplorerPage({ connectionId }: ObjectExplorerPageProps) {
if (type === "extensions") {
result = await cmd.getExtensions(connectionId);
} else if (type === "functions") {
result = await cmd.getFunctions(
result = (await cmd.getFunctions(
connectionId,
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") {
result = await cmd.getTriggers(
connectionId,
@@ -886,6 +1102,16 @@ export function ObjectExplorerPage({ connectionId }: ObjectExplorerPageProps) {
connectionId,
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 {
result = [];
}
@@ -1087,11 +1313,9 @@ export function ObjectExplorerPage({ connectionId }: ObjectExplorerPageProps) {
{!loading && !error && filtered.length === 0 && (
<div className="px-3 py-2 text-sm text-text-muted">
{items === null
? `No ${label.toLowerCase()} found`
: searchQuery
? `No ${label.toLowerCase()} matching "${searchQuery}"`
: `No ${label.toLowerCase()} found in ${currentSchema || "current schema"}`}
{searchQuery
? `No ${emptyPlural(type)} matching "${searchQuery}"`
: `No ${emptyPlural(type)} found`}
</div>
)}
+2 -2
View File
@@ -3,7 +3,7 @@ import { ListChecks, Play, Table2, Terminal, X } from "lucide-react";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { ChangesQueuePanel } from "./ChangesQueuePanel";
export function TabBar() {
export function TabBar({ onCommitted }: { onCommitted?: () => void } = {}) {
const tabs = useDbViewerStore((state) => state.tabs);
const activeTabId = useDbViewerStore((state) => state.activeTabId);
const closeTab = useDbViewerStore((state) => state.closeTab);
@@ -129,7 +129,7 @@ export function TabBar() {
</button>
{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">
<ChangesQueuePanel />
<ChangesQueuePanel onCommitted={onCommitted} />
</div>
)}
</div>
@@ -15,6 +15,8 @@ const columns = [
is_fk: false,
fk_ref: null,
default_value: null,
editable: true,
is_generated: false,
},
];
@@ -314,4 +316,19 @@ describe("TableControls", () => {
});
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,
ChevronDown, FileJson, FileText, Terminal,
} 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 { exportData } from "../../lib/exportData";
import type { ColumnInfo } from "../../lib/types";
@@ -27,19 +28,6 @@ const EXPORT_FORMATS = [
{ label: "Markdown", ext: "md" },
] 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 ────────────────────────────────────────────
/**
@@ -136,6 +124,7 @@ function FilterModal({
<X size={14} />
</button>
</div>
<FilterBuilder columns={columns} rules={rules} onChange={onChange} />
{rules.map((rule) => (
<div key={rule.id} className="flex items-center gap-1.5 mb-1.5">
<select
@@ -414,6 +403,8 @@ interface TableControlsProps {
selectedRows: unknown[][];
onClearSelection: () => void;
defaultRefreshRate?: number;
/** Hide data-modifying affordances (e.g. for materialized views). */
isMatview?: boolean;
/** "table" = full table toolbar; "query" = export/refresh/columns + timing */
variant?: "table" | "query";
}
@@ -435,6 +426,7 @@ export function TableControls({
selectedRows,
onClearSelection,
defaultRefreshRate = 0,
isMatview = false,
variant = "table",
}: TableControlsProps) {
const isQuery = variant === "query";
@@ -630,6 +622,8 @@ export function TableControls({
{columnsControl}
</>
) : (
<>
{!isMatview && (
<>
{/* Insert Row */}
<Tooltip content="Insert row" side="bottom">
@@ -642,6 +636,8 @@ export function TableControls({
<Plus size={14} />
</button>
</Tooltip>
</>
)}
{refreshControl}
@@ -75,7 +75,7 @@ describe("TableOverflowMenu", () => {
it("Export data calls exportData when rows and columns are provided", async () => {
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]]} />);
fireEvent.click(screen.getByLabelText(/table options/i));
fireEvent.click(screen.getByText(/export data \(csv\)/i));
@@ -23,6 +23,23 @@ describe("TableTree", () => {
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 () => {
const user = userEvent.setup();
useDbViewerStore.setState({
+10 -2
View File
@@ -1,5 +1,5 @@
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 { useUiStore } from "../../stores/uiStore";
import { TableOverflowMenu } from "./TableOverflowMenu";
@@ -70,6 +70,9 @@ export function TableTree({ searchQuery }: { searchQuery?: string }) {
const key = `${table.schema}.${table.name}`;
const isExpanded = expanded.has(key);
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 (
<div key={key}>
<div
@@ -90,10 +93,15 @@ export function TableTree({ searchQuery }: { searchQuery?: string }) {
<ChevronRight size={14} />
)}
</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">
{table.name}
</span>
{typeLabel && (
<span className="text-[10px] text-text-subtle shrink-0">
{typeLabel}
</span>
)}
<div onClick={(e) => e.stopPropagation()}>
<TableOverflowMenu
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";
const mockColumns: ColumnInfo[] = [
{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, 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 },
{ 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 },
];
const mockRows: unknown[][] = [
@@ -36,6 +36,78 @@ describe("VirtualDataGrid", () => {
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", () => {
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(
@@ -51,12 +123,15 @@ describe("VirtualDataGrid", () => {
<VirtualDataGrid
connectionId="conn-1"
schema="public"
table="users"
rows={mockRows}
columns={mockColumns}
hiddenColumns={new Set()}
selectedRows={new Set()}
onToggleRow={() => {}}
onToggleAll={() => {}}
dbType="postgresql"
tabType="table"
/>,
);
@@ -79,12 +154,15 @@ describe("VirtualDataGrid", () => {
<VirtualDataGrid
connectionId="conn-1"
schema="public"
table="users"
rows={mockRows}
columns={mockColumns}
hiddenColumns={new Set()}
selectedRows={new Set()}
onToggleRow={() => {}}
onToggleAll={() => {}}
dbType="postgresql"
tabType="table"
/>,
);
@@ -109,12 +187,15 @@ describe("VirtualDataGrid", () => {
<VirtualDataGrid
connectionId="conn-1"
schema="public"
table="users"
rows={rows}
columns={mockColumns}
hiddenColumns={new Set()}
selectedRows={new Set()}
onToggleRow={() => {}}
onToggleAll={() => {}}
dbType="postgresql"
tabType="table"
/>,
);
@@ -130,12 +211,15 @@ describe("VirtualDataGrid", () => {
<VirtualDataGrid
connectionId="conn-1"
schema="public"
table="users"
rows={[]}
columns={mockColumns}
hiddenColumns={new Set()}
selectedRows={new Set()}
onToggleRow={() => {}}
onToggleAll={() => {}}
dbType="postgresql"
tabType="table"
/>,
);
@@ -147,41 +231,43 @@ describe("VirtualDataGrid", () => {
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" rows={mockRows} columns={mockColumns}
render(<VirtualDataGrid connectionId="conn-1" schema="public" table="users" rows={mockRows} columns={mockColumns}
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");
fireEvent.click(checkboxes[1]); // first row checkbox
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[] = [
{ 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);
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()}
onToggleRow={() => {}} onToggleAll={() => {}} />);
onToggleRow={() => {}} onToggleAll={() => {}}
dbType="postgresql" tabType="table" />);
const fkCell = screen.getByText("42");
expect(fkCell.className).toContain("cursor-pointer");
expect(fkCell.className).toContain("underline");
expect(screen.getByText("42")).toBeInTheDocument();
expect(screen.getByLabelText("Open FK reference")).toBeInTheDocument();
});
it("renders JSON cells with preview label", () => {
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);
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()}
onToggleRow={() => {}} onToggleAll={() => {}} />);
onToggleRow={() => {}} onToggleAll={() => {}}
dbType="postgresql" tabType="table" />);
expect(screen.getByText(/2 keys/)).toBeInTheDocument();
});
@@ -194,12 +280,15 @@ describe("VirtualDataGrid", () => {
<VirtualDataGrid
connectionId="conn-1"
schema="public"
table="users"
rows={[]}
columns={mockColumns}
hiddenColumns={new Set()}
selectedRows={new Set()}
onToggleRow={() => {}}
onToggleAll={() => {}}
dbType="postgresql"
tabType="table"
/>,
);
@@ -225,12 +314,15 @@ describe("VirtualDataGrid", () => {
<VirtualDataGrid
connectionId="conn-1"
schema="public"
table="users"
rows={[]}
columns={[]}
hiddenColumns={new Set()}
selectedRows={new Set()}
onToggleRow={() => {}}
onToggleAll={() => {}}
dbType="postgresql"
tabType="table"
/>,
);
@@ -244,9 +336,10 @@ describe("VirtualDataGrid", () => {
mockGetTotalSize.mockReturnValue(0);
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()}
onToggleRow={() => {}} onToggleAll={() => {}} />);
onToggleRow={() => {}} onToggleAll={() => {}}
dbType="postgresql" tabType="table" />);
const handles = document.querySelectorAll('[class*="cursor-col-resize"]');
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 }))
);
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()}
onToggleRow={() => {}} onToggleAll={() => {}} />,
onToggleRow={() => {}} onToggleAll={() => {}}
dbType="postgresql" tabType="table" />,
);
const checkboxes = screen.getAllByRole("checkbox");
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 }))
);
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}
onToggleRow={() => {}} onToggleAll={() => {}} />,
onToggleRow={() => {}} onToggleAll={() => {}}
dbType="postgresql" tabType="table" />,
);
const selectAll = screen.getAllByRole("checkbox")[0] as HTMLInputElement;
expect(selectAll.checked).toBe(true);
@@ -289,11 +384,234 @@ describe("VirtualDataGrid", () => {
mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 }))
);
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()}
onToggleRow={() => {}} onToggleAll={() => {}} />,
onToggleRow={() => {}} onToggleAll={() => {}}
dbType="postgresql" tabType="table" />,
);
expect(screen.queryByText("name")).not.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 { 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 { abbreviateType } from "../../lib/utils";
import { FkPreviewPopover } from "../db-viewer/FkPreviewPopover";
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 {
connectionId: string;
schema: string;
table?: string;
rows: unknown[][];
columns: ColumnInfo[];
hiddenColumns: Set<string>;
selectedRows: Set<number>;
onToggleRow: (rowIndex: number) => 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;
@@ -25,12 +55,25 @@ const MAX_COL_WIDTH = 800;
export function VirtualDataGrid({
connectionId,
schema,
table = "",
rows,
columns,
hiddenColumns,
selectedRows,
onToggleRow,
onToggleAll,
dbType = "postgresql",
tabType = "table",
onStageEdit,
onOpenRowDetail,
getLocator,
readOnly = false,
pendingCell = null,
enumValues,
fkOptions,
fkPlaceholders,
stagedValues,
pendingKeys,
}: VirtualDataGridProps) {
const parentRef = useRef<HTMLDivElement>(null);
@@ -53,6 +96,45 @@ export function VirtualDataGrid({
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 ─────────────────────────────────────
const [colWidths, setColWidths] = useState<Record<string, number>>({});
@@ -138,44 +220,128 @@ export function VirtualDataGrid({
anchorRect: DOMRect | 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) ──
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 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 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) => {
if (isJson) {
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 (
<div
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" : ""
} ${isJson ? "cursor-pointer text-accent/80 hover:text-accent" : ""}`}
role={isFk || isJson ? "button" : undefined}
tabIndex={isFk || isJson ? 0 : undefined}
} ${isJson ? "cursor-pointer text-accent/80 hover:text-accent" : ""} ${
isActive ? "bg-accent/10 ring-1 ring-inset ring-accent outline-none" : ""
}`}
role={isJson ? "button" : undefined}
tabIndex={isJson ? 0 : -1}
onKeyDown={
isFk || isJson
isJson
? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (isFk) handleFkClick(col, cell, e as any);
else if (isJson) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setJsonPopover({ value: cell, anchorRect: rect });
}
}
}
: undefined
}
style={{ width: getWidth(col.name), flexShrink: 0 }}
@@ -183,37 +349,128 @@ export function VirtualDataGrid({
isNull
? "NULL"
: isFk
? `FK → ${col.fk_ref![0]}.${col.fk_ref![1]}: ${String(cell)}`
? `FK → ${col.fk_ref![0]}.${col.fk_ref![1]}: ${String(displayCell)}`
: isJson
? "Click to view JSON"
: String(cell)
}
onClick={
isFk
? (e) => handleFkClick(col, cell, e)
: isJson
? handleJsonClick
: undefined
: String(displayCell)
}
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>
) : isJson ? (
<span className="inline-flex items-center gap-0.5">
<Braces size={10} className="shrink-0" />
{jp.label}
</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>
);
},
[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 (
<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) ── */}
{hasColumns && (
<div className="sticky top-0 z-10">
@@ -285,7 +542,10 @@ export function VirtualDataGrid({
}}
>
{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
type="checkbox"
checked={isSelected}
@@ -294,7 +554,7 @@ export function VirtualDataGrid({
/>
</div>
)}
{visibleColumns.map((col) => renderCell(col, row, virtualRow.index))}
{visibleColumns.map((col, i) => renderCell(col, row, virtualRow.index, i))}
</div>
);
})}
@@ -321,6 +581,69 @@ export function VirtualDataGrid({
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>
);
}
@@ -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 userEvent from "@testing-library/user-event";
import { ActionRow } from "./ActionRow";
import { useUiStore } from "../../stores/uiStore";
beforeEach(() => useUiStore.setState({ activeView: "home" }));
beforeEach(() => useUiStore.setState({ activeView: "home", selectedItemIds: [] }));
describe("ActionRow", () => {
it("renders Saved Connections title", () => {
@@ -21,4 +21,11 @@ describe("ActionRow", () => {
await userEvent.click(screen.getByText(/settings/i));
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 { 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 { useUiStore } from "../../stores/uiStore";
import { ImportExportMenu } from "./ImportExportMenu";
@@ -12,10 +12,11 @@ interface ActionRowProps {
onNewFolder?: () => void;
onFilters?: () => void;
onDeleteSelected?: () => void;
onMoveToFolder?: () => void;
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 openSettings = useUiStore((s) => s.openSettings);
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 ?? (() => {})}>
<FolderPlus size={14} /> New Folder
</Button>
{hasSelection && (
<Button variant="ghost" className="text-xs border-0" onClick={onMoveToFolder}>
<FolderInput size={14} /> Move to folder
</Button>
)}
{hasSelection && (
<div className="relative" ref={menuRef}>
<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 { render, screen } from "@testing-library/react";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { HomeScreen } from "./HomeScreen";
import { useConnectionStore } from "../../stores/connectionStore";
@@ -12,6 +12,8 @@ vi.mock("../../lib/commands", () => ({
getFolders: vi.fn().mockResolvedValue([]),
getTags: 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),
deleteConnectionPassword: vi.fn().mockResolvedValue(undefined),
deleteFolder: vi.fn().mockResolvedValue(undefined),
@@ -25,7 +27,7 @@ vi.mock("@tauri-apps/plugin-fs", () => ({
describe("HomeScreen", () => {
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: [] });
useSettingsStore.setState({ settings: null, loading: false, error: null });
// 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.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 {
@@ -138,6 +217,7 @@ function makeConnection(id: string): Connection {
folder_id: null,
keychain_ref: null,
tag_ids: [],
favorite: false,
created_at: "",
updated_at: "",
};
+90 -2
View File
@@ -13,10 +13,13 @@ import { ConnectionCard } from "../connections/ConnectionCard";
import { CreateFolderDialog } from "../folders/CreateFolderDialog";
import { EditFolderDialog } from "../folders/EditFolderDialog";
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 { getChildFolders } from "../../lib/utils";
import { useShortcut } from "../../hooks/useShortcut";
import type { Folder } from "../../lib/types";
import type { Connection, Folder } from "../../lib/types";
export function HomeScreen() {
const connections = useFilteredConnections();
@@ -31,15 +34,22 @@ export function HomeScreen() {
const deleteFolder = useConnectionStore((s) => s.deleteFolder);
const deleteConnection = useConnectionStore((s) => s.deleteConnection);
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 clearSelection = useUiStore((s) => s.clearSelection);
const confirmBeforeDelete =
useSettingsStore((s) => s.settings?.confirm_before_delete ?? true);
const [folderDialogOpen, setFolderDialogOpen] = useState(false);
const [editFolder, setEditFolder] = useState<Folder | null>(null);
const [moveToFolderOpen, setMoveToFolderOpen] = useState(false);
const [editingConnection, setEditingConnection] = useState<Connection | null>(null);
const [confirmDelete, setConfirmDelete] = useState<{
type: "folder" | "selected";
type: "folder" | "selected" | "connection";
folder?: Folder;
connection?: Connection;
} | null>(null);
const [activeDragId, setActiveDragId] = useState<string | null>(null);
const searchRef = useRef<SearchBarHandle>(null);
@@ -51,6 +61,7 @@ export function HomeScreen() {
const setActiveConnectionId = useUiStore((s) => s.setActiveConnectionId);
const handleOpenDbViewer = (connectionId: string) => {
recordRecent(connectionId);
setActiveConnectionId(connectionId);
setActiveView("db-viewer");
};
@@ -119,6 +130,11 @@ export function HomeScreen() {
}
}, [folders, activeFolderId, setActiveFolderId]);
// Load recent connections on mount for the root strip
useEffect(() => {
loadRecent();
}, [loadRecent]);
const executeDeleteSelected = async () => {
const folderIds = new Set(folders.map((f) => f.id));
for (const id of selectedItemIds) {
@@ -148,6 +164,24 @@ export function HomeScreen() {
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 (
<main className="min-h-full p-6 bg-canvas select-none max-w-7xl mx-auto">
<div className="mb-6">
@@ -168,9 +202,13 @@ export function HomeScreen() {
? setConfirmDelete({ type: "selected" })
: executeDeleteSelected()
}
onMoveToFolder={() => setMoveToFolderOpen(true)}
visibleItemIds={visibleItemIds}
/>
</div>
{activeFolderId === null && !searchQuery && (
<RecentConnectionsStrip recents={recent} onOpen={handleOpenDbViewer} />
)}
<DndContext
onDragStart={(event) => setActiveDragId(event.active.id as string)}
onDragEnd={async (event) => {
@@ -194,6 +232,9 @@ export function HomeScreen() {
? setConfirmDelete({ type: "folder", folder: f })
: executeDeleteFolder(f)
}
onEditConnection={setEditingConnection}
onDuplicateConnection={handleDuplicateConnection}
onDeleteConnection={handleDeleteConnection}
/>
<DragOverlay dropAnimation={null}>
{activeDragId && connections.find((c) => c.id === activeDragId) ? (
@@ -242,6 +283,29 @@ export function HomeScreen() {
}}
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" && (
<ConfirmDialog
open
@@ -264,6 +328,30 @@ export function HomeScreen() {
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>
);
}
+10
View File
@@ -28,4 +28,14 @@ describe("SearchBar", () => {
fireEvent.change(input, { target: { value: "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 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"
/>
<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_cert_path: null,
ssl_key_path: null,
favorite: false,
...overrides,
});
+56
View File
@@ -24,6 +24,12 @@ import {
getSavedQueries,
updateSavedQuery,
deleteSavedQuery,
setConnectionFavorite,
recordRecentConnection,
getRecentConnections,
clearRecentConnections,
getIndexes,
getConstraints,
} from "./commands";
import type { SchemaGraph } from "./types";
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 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 { ChangePayload } from "./changePayload";
@@ -269,3 +269,29 @@ export async function updateSavedQuery(
export async function deleteSavedQuery(id: string): Promise<void> {
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";
const columns: ColumnInfo[] = [
{ name: "id", data_type: "int", is_nullable: false, is_pk: true, 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 },
{ 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, editable: true, is_generated: false },
];
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 {
Connection,
ConnectionInput,
@@ -16,6 +16,9 @@ import type {
GraphColumn,
Relationship,
Settings,
IndexInfo,
ConstraintInfo,
RecentConnection,
} from "./types";
describe("ActiveView", () => {
@@ -37,6 +40,7 @@ describe("Connection", () => {
folder_id: null,
keychain_ref: null,
tag_ids: [],
favorite: false,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
// new SSH/SSL fields
@@ -68,6 +72,7 @@ describe("Connection", () => {
folder_id: null,
keychain_ref: null,
tag_ids: [],
favorite: false,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
};
@@ -89,6 +94,7 @@ describe("Connection", () => {
folder_id: null,
keychain_ref: null,
tag_ids: [],
favorite: false,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
};
@@ -184,6 +190,8 @@ describe("ColumnInfo", () => {
is_fk: false,
fk_ref: null,
default_value: null,
editable: true,
is_generated: false,
};
expect(col.name).toBe("id");
expect(col.is_pk).toBe(true);
@@ -198,6 +206,8 @@ describe("ColumnInfo", () => {
is_fk: true,
fk_ref: ["users", "id"],
default_value: null,
editable: true,
is_generated: false,
};
expect(col.fk_ref?.[0]).toBe("users");
});
@@ -206,7 +216,7 @@ describe("ColumnInfo", () => {
describe("QueryResult", () => {
it("is well-typed with columns and rows", () => {
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: [
[1, "Alice"],
[2, "Bob"],
@@ -231,7 +241,7 @@ describe("QueryResult", () => {
it("can have null execution_time", () => {
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: [],
total_rows: 0, page: 1, page_size: 50,
execution_time_ms: null,
@@ -324,7 +334,7 @@ describe("DbViewerTab", () => {
title: "SELECT * FROM users",
query: "SELECT * FROM users",
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: [],
total_rows: 0, page: 1, page_size: 50,
},
@@ -453,3 +463,36 @@ describe("Settings", () => {
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;
// Environment label (production, staging, development, etc.)
environment?: string | null;
// Favorite flag (v0.5.0 — pinned connection)
favorite: boolean;
}
export type NewConnectionMode = "simple" | "detailed";
@@ -131,7 +133,7 @@ export interface ImportResult {
export interface TableInfo {
name: string;
schema: string;
table_type: "TABLE" | "VIEW";
table_type: "TABLE" | "VIEW" | "MATERIALIZED VIEW";
columns?: ColumnInfo[];
}
@@ -143,6 +145,36 @@ export interface ColumnInfo {
is_fk: boolean;
fk_ref: [string, 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 {
+1
View File
@@ -23,6 +23,7 @@ const makeConnection = (over: Partial<Connection> = {}): Connection => ({
keychain_ref: null,
tag_ids: [],
environment: null,
favorite: false,
created_at: "2026-07-26T00:00:00Z",
updated_at: "2026-07-26T00:00:00Z",
...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 => ({
id: "c1", name: "P", db_type: "postgresql", host: "h", port: 5432,
username: null, folder_id: null, keychain_ref: null, tag_ids: [],
created_at: "", updated_at: "", ...over,
favorite: false, created_at: "", updated_at: "", ...over,
});
beforeEach(() => {
@@ -82,6 +82,7 @@ describe("moveConnection", () => {
ssl_cert_path: null,
ssl_key_path: null,
tag_ids: [],
favorite: false,
created_at: "2024-01-01",
updated_at: "2024-01-01",
};
@@ -189,3 +190,77 @@ describe("moveConnection", () => {
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 {
connections: Connection[]; folders: Folder[]; tags: Tag[];
tagOrder: string[];
recent: Connection[];
loading: boolean; error: string | null;
loadAll: () => Promise<void>;
loadRecent: () => Promise<void>;
recordRecent: (id: string) => Promise<void>;
toggleFavorite: (id: string) => Promise<void>;
loadTagOrder: () => Promise<void>;
setTagOrder: (order: string[]) => Promise<void>;
createConnection: (input: ConnectionInput) => Promise<void>;
createConnection: (input: ConnectionInput) => Promise<Connection>;
deleteConnection: (id: string) => Promise<void>;
duplicateConnection: (id: string) => Promise<Connection>;
createFolder: (input: FolderInput) => Promise<void>;
updateFolder: (id: string, input: FolderInput) => Promise<void>;
deleteFolder: (id: string) => Promise<void>;
@@ -19,23 +24,48 @@ interface ConnectionState {
deleteTag: (id: string) => Promise<void>;
addTagToItems: (tagId: string, folderIds: string[], connectionIds: string[]) => 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>;
getConnectionPassword: (connectionId: string) => Promise<string | null>;
}
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 () => {
set({ loading: true, error: null });
try {
const [connections, folders, tags] = await Promise.all([cmd.getConnections(), cmd.getFolders(), cmd.getTags()]);
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
get().loadTagOrder();
} catch (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 () => {
try {
const settings = await cmd.getSettings();
@@ -66,6 +96,36 @@ export const useConnectionStore = create<ConnectionState>((set, get) => ({
await cmd.saveConnectionSshPassphrase(conn.id, input.ssh_passphrase);
}
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) => {
await cmd.deleteConnection(id);
@@ -173,4 +233,32 @@ export const useConnectionStore = create<ConnectionState>((set, get) => ({
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);
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"]],
total_rows: 1, page: 1, page_size: 50,
};
@@ -231,6 +231,76 @@ describe("dbViewerStore", () => {
expect(state.schemas).toEqual(["public", "private"]);
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", () => {
+58 -1
View File
@@ -1,5 +1,5 @@
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";
// ─── Local types ────────────────────────────────────────────────
@@ -96,6 +96,8 @@ interface DbViewerState {
sequences: SequenceInfo[] | null;
enums: EnumInfo[] | null;
extensions: ExtensionInfo[] | null;
indexes: IndexInfo[] | null;
constraints: ConstraintInfo[] | null;
// Actions
openTab: (schema: string, table: string, forceNew?: boolean) => void;
@@ -141,6 +143,17 @@ interface DbViewerState {
setSequences: (sequences: SequenceInfo[]) => void;
setEnums: (enums: EnumInfo[]) => 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: (
databases: string[],
schemas: string[],
@@ -168,6 +181,8 @@ const initialState = {
sequences: null as SequenceInfo[] | null,
enums: null as EnumInfo[] | null,
extensions: null as ExtensionInfo[] | null,
indexes: null as IndexInfo[] | null,
constraints: null as ConstraintInfo[] | null,
};
// ─── Store ──────────────────────────────────────────────────────
@@ -400,6 +415,48 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
setSequences: (sequences) => set({ sequences }),
setEnums: (enums) => set({ enums }),
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) =>
set({ databases, schemas, tables }),