DB viewer + query editor enhancements (home-screen-ux-query-editor) (#4)

* feat: add query_history table migration (v5) (Task 1)

* feat: add isDestructiveQuery utility (Task 2)

* feat: add tabType discriminator and openQueryTab to dbViewerStore (Task 3)

* feat: add execute_query command with pagination and query history (Task 4)

* feat: add typed wrappers for executeQuery, getQueryHistory, clearQueryHistory (Task 5)

* fix: global search bypasses folder scope when filters active (Task 6)

* feat: add TagFilterDropdown with checkboxes and empty state (Task 7)

* feat: add DbTypeFilterDropdown with checkboxes and clear all (Task 8)

* feat: wire TagFilterDropdown/DbTypeFilterDropdown into ActionRow, add inline tag creation (Task 9)

* feat: add Name input to GeneralTab for connection editing (Task 10)

* feat: add QueryEditor Monaco wrapper with SQL mode and Cmd+Enter (Task 11)

* feat: add DestructiveQueryDialog with SQL preview and confirmation (Task 12)

* feat: integrate query tabs, Monaco editor, destructive guard into DbViewerScreen (Task 13)

* fix: harden moveConnection against race conditions on rapid drags (Task 14)

* docs: update AGENTS.md implementation status for Home Screen UX + Query Editor (Task 15)

* feat: switch tag filter to OR semantics, add environment filter (F-T16)

* feat: add activeEnvironment filter state to uiStore and useFilteredConnections (F-T17)

* feat: add environment filter select to Filters dropdown (F-T18)

* feat: filter folder cards by tag match or contained connections (F-T19)

* fix: keep grid header width to content, border last column

* fix: hide select-all checkbox and empty-state when no table open

* fix: filter folder cards by any active filter, show global search results (F-T20)

* feat: show 'Showing Search Results' breadcrumb with clear button (F-T21)

* docs: update README + AGENTS.md for Query Editor, filters, and planned AI integration (BYOK)

* feat: refresh indicator with spinning icon and pulse, defer auto-refresh on tab switch

* feat: smart default schema selection, refresh schemas on database switch

* fix: auto-refresh waits for in-flight refresh to complete before next tick

* style: shrink db viewer sidebar nav icons from 20px to 16px

* style: shrink db viewer sidebar nav buttons to 32px (8px padding)

* style: make Tables panel title xs, regular weight, muted

* style: bump Tables panel title back to sm, keep regular weight and muted

* feat: export schema diagram as PNG/JPEG/SVG (entire schema or viewport)

* chore: lockfile for html-to-image

* fix: raise schema visualizer toolbar above legend so export menu isn't hidden

* feat: schema export via save dialog, transparent background option, save notification

* fix: render nothing in tab bar when no tabs are open

* style: reduce tab bar height from 40px to 36px

* style: reduce tab bar height to 32px

* style: revert tab bar height to 36px

* feat: split tab bar with fixed +Query and Changes actions on the right

* style: blue play-icon Query button in tab bar

* refactor: remove sidebar New Query button (now in tab bar)

* style: conditional bottom padding in sidebar toolbar when nothing is below

* feat: distinguish table and query tabs with icons

* style: tab icons follow active/inactive state, muted colors

* feat: query tab toolbar (run/format/dialect badge) + bare transparent editor

* style: blue rounded Run Query button in query toolbar

* feat: smart platform-aware shortcut tooltip on Run Query (⌘+⏎ / Ctrl+Enter)

* style: show only the shortcut in the Run Query tooltip

* fix: Cmd+Enter keybinding stale closure; add run pulse to query toolbar; bundle monaco locally (offline)

* feat: show placeholder text in empty query editor

* feat: SQL autocomplete — keywords + table names from active schema

* feat: per-table column autocomplete on 'table.' + docs update

* feat: query-variant result toolbar — export/refresh/columns left, smart-unit execution time right

* fix: populate execution_time_ms on query results so the toolbar can show time taken

* fix: re-measure monaco fonts after async font load to stop cursor drift

* feat: resizable + collapsible query results panel

* refactor: move results caret onto the resize handle (centered), bottom caret when collapsed

* style: thin drag strip with caret on its own centered pill

* refactor: remove Queue button from table toolbar (Changes lives in tab bar)

* style: changes button becomes bordered rounded icon with count badge

* docs: mark tab-bar Changes queue button in AGENTS.md and README
This commit is contained in:
2026-08-01 05:56:47 +08:00
committed by GitHub
parent 4f18993e70
commit 80962d7d11
66 changed files with 5681 additions and 509 deletions
+19 -8
View File
@@ -203,11 +203,16 @@ cargo test # Rust tests
| Connection cards grid (by folder) | ✅ | Grouped display, single-click to open DB viewer |
| Folders CRUD | ✅ | Nested folders, reparent on delete, breadcrumb nav |
| Tags CRUD | ✅ | Colors, drag reorder, filter connections by tag |
| DB type filter (Postgres/MySQL/SQLite/Redis) | ✅ | Toggle chips to filter connection grid |
| Global search (Cmd+K) | ✅ | Connection URL detection auto-fills new-connection form |
| Tag filter dropdown | ✅ | ActionRow Tags button → dropdown with checkboxes, active-count badge, Manage tags → Settings. **OR semantics** — a connection shows if it has ANY selected tag (not all) |
| 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 |
| 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 | | Currently only via edit form |
| 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 | ❌ | |
@@ -232,7 +237,7 @@ cargo test # Rust tests
| Row selection (checkboxes + select all) | ✅ | Bulk copy (JSON/CSV/SQL) and delete |
| Export toolbar (JSON, CSV, SQL, Markdown) | ✅ | Client-side Blob download of visible rows |
| Auto-refresh timer | ✅ | Configurable interval in settings |
| Changes queue (INSERT, UPDATE, DELETE) | ✅ | Queue changes → Commit All; cancel individual changes |
| Changes queue (INSERT, UPDATE, DELETE) | ✅ | Queue changes → Commit All; cancel individual changes. Tab bar shows a **Changes** icon button with a pending-count badge that toggles the bottom panel (the queue dropdown was removed from the table toolbar — one entry point only) |
| 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 |
@@ -258,11 +263,12 @@ cargo test # Rust tests
### Query Editor
| Feature | Status | Details |
| :--- | :---: | :--- |
| SQL text editor (Monaco) | | `src/components/editor/` does not exist yet |
| SQL autocomplete (keywords, tables, columns) | ❌ | |
| Custom query execution (arbitrary SQL) | | Only `SELECT * FROM table` via tab open |
| SQL text editor (Monaco) | | Lazy-loaded Monaco SQL editor with Cmd/Ctrl+Enter to run (`src/components/editor/QueryEditor.tsx`) |
| Custom query execution (arbitrary SQL) | ✅ | `execute_query` Rust command: subquery-wrapped pagination + raw fallback; PostgreSQL + SQLite; results in virtualized grid |
| Destructive query guard | | Confirmation dialog for INSERT/UPDATE/DELETE/DROP/ALTER/TRUNCATE/CREATE/REPLACE (`isDestructiveQuery` + `DestructiveQueryDialog`) |
| Query history / recent queries | 🟡 | Backend + commands done (v5 `query_history` table, `get_query_history`/`clear_query_history`); UI dropdown to show history in the query editor is **not wired yet** |
| SQL autocomplete (keywords, tables, columns) | ✅ | Completion provider in `src/lib/monacoSetup.ts` backed by `src/lib/sqlCompletion.ts` (pure, unit-tested): keywords (~60) + table names from the active schema; typing `table.` or `schema.table.` suggests that table's columns (introspected via `get_schema_graph`, cached per schema in memory, `incomplete: true` warm-up on first use) |
| Multiple result sets | ❌ | |
| Query history / recent queries | ❌ | No persistence or UI |
| Saved queries (named, organized) | ❌ | No `queries` table in local SQLite |
| Query favorites / pinning | ❌ | |
| Editor settings (font, tab size, word wrap, minimap) | ❌ | Settings page has "Editor" tab with "coming soon" placeholder |
@@ -303,6 +309,11 @@ cargo test # Rust tests
| Getting started / onboarding flow | ❌ | |
| Welcome tooltips / tour | ❌ | |
### AI Integration (Future Planning)
| Feature | Status | Details |
| :--- | :---: | :--- |
| AI assistant (BYOK) | 🔮 | **Planned for future.** Bring-Your-Own-Key model (user supplies their own API key — no paywall, no bundling). Intended use cases: natural-language → SQL generation, query explanations, schema summaries, error message suggestions. No design decided yet — deliberate before implementation (privacy: SQL/text only sent to user's chosen provider, key stored in OS keychain like DB passwords). |
---
## Related Documents
+16 -8
View File
@@ -25,9 +25,9 @@ Most database GUI clients either lock essential productivity features behind pay
| SSH tunneling | 🟡 (likely paid) | ✅ | 🟡 *Config UI done* |
| OS credential vault | ✅ | ✅ | **Keychain / Secret Service** |
| Workspace / folder hierarchy | ❌ | ❌ | **Multi-level tree + tags** |
| Changes queue (stage & commit) | ❌ | ❌ | **✅ Queue → Commit All** |
| Query history | ✅ (auto-saved) | ✅ | 🟡 *Upcoming* |
| AI assistant | ✅ (BYO key) | ❌ (paid only) | |
| Changes queue (stage & commit) | ❌ | ❌ | **✅ Queue → Commit All** (tab-bar **Changes** button with count badge toggles the commit panel) |
| Query history | ✅ (auto-saved) | ✅ | 🟡 *Backend done, UI pending* |
| AI assistant | ✅ (BYO key) | ❌ (paid only) | 🔮 *Planned — BYOK* |
| Open source | ❌ | ✅ (GPLv3) | **✅ (MIT)** |
| Desktop shell | Native webview | Electron (~250MB) | **Tauri 2.0 (~40MB)** |
@@ -96,10 +96,15 @@ Full tree-view navigation of all native PostgreSQL schema objects:
- **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
### SQL Editor & Query Workbench
- **Multi-Tab Workspace** — unlimited named tabs, close with Cmd/Ctrl+W, session persistence across restarts
- **Changes Queue** — queue INSERT/UPDATE/DELETE changes; preview before committing all
- **Monaco SQL Editor** — lazy-loaded [Monaco Editor](https://microsoft.github.io/monaco-editor/) with SQL syntax highlighting, Cmd/Ctrl+Enter to run
- **Custom Query Execution** — run arbitrary SQL on PostgreSQL + SQLite via the Rust `execute_query` command; subquery-wrapped pagination with automatic raw fallback for CTEs/multi-statement SQL
- **Destructive Query Guard** — confirmation dialog for INSERT/UPDATE/DELETE/DROP/ALTER/TRUNCATE/CREATE/REPLACE before execution
- **Query Tabs** — dedicated query tabs alongside table tabs, results rendered in the same virtualized data grid, close with Cmd/Ctrl+W
- **SQL Autocomplete** — keyword + table suggestions from the active schema; typing `table.` suggests that table's columns (schema introspection, cached per schema)
- **Multi-Tab Workspace** — unlimited named tabs, session persistence across restarts
- **Changes Queue** — queue INSERT/UPDATE/DELETE changes; preview before committing all. The tab bar's **Changes** button (checklist icon + pending-count badge) toggles the bottom Commit All panel — the single entry point
- **Smart Default Sort** — auto-detects `updated_at`, `created_at`, `_id` columns for logical initial sorting
- *(Monaco Editor with SQL autocomplete, query history, and saved snippets — upcoming)*
- *(query history UI dropdown, saved queries/snippets — upcoming)*
### Data Grid & Schema Browser
- **Virtualized Grid** — row-level virtualization via `@tanstack/react-virtual` handles 100k+ rows
@@ -133,7 +138,7 @@ Full tree-view navigation of all native PostgreSQL schema objects:
| **Frontend** | [React 19](https://react.dev) + [TypeScript](https://www.typescriptlang.org) | Component-based UI |
| **Styling** | [Tailwind CSS](https://tailwindcss.com) | Utility-first, dark mode, glassmorphic design |
| **State** | [Zustand](https://zustand.docs.pmnd.rs) / [Jotai](https://jotai.org) | Lightweight client-state for tabs, connections, queries |
| **Code Editor** | *(planned)* [Monaco Editor](https://microsoft.github.io/monaco-editor/) | IDE-grade SQL editing with autocomplete (coming soon) |
| **Code Editor** | [Monaco Editor](https://microsoft.github.io/monaco-editor/) | IDE-grade SQL editing with Cmd+Enter execution, lazy-loaded |
| **Data Grid** | [TanStack Virtual](https://tanstack.com/virtual) | Virtualized row rendering for 100k+ rows |
| **Local DB** | SQLite via [rusqlite](https://github.com/rusqlite/rusqlite) | User settings, saved queries, workspace state |
@@ -225,9 +230,11 @@ gridline/
- **Phase 5 — Admin Tools** — `pg_dump`/`pg_restore` UI wrappers with real-time progress, DB-to-DB sync, backup/restore format selectors
- **Phase 6 — Schema Visualizer** — Interactive ER diagram with React Flow + dagre, crow's foot notation, schema selector, legend, collapsible column views, PostgreSQL + SQLite support
- **Home Screen & Organization** — Connection cards by folder, folders CRUD, tags CRUD with colors, global search (Cmd+K), import/export connections (JSON), bulk select/delete, DB type filter, demo SQLite database
- **Query Editor (Core)** — Monaco SQL editor with Cmd+Enter execution, `execute_query` Rust command (PostgreSQL + SQLite, subquery pagination with raw fallback), query tabs in the DB viewer, destructive query confirmation dialog, `query_history` persistence backend
- **Home Screen Filters** — Tag filter with OR semantics, folder cards matching tags or containing matching connections, DB type filter hiding empty folders, environment filter (All/Production/Staging/Development/None), global search across all folders with "Showing Search Results" breadcrumb + Clear
### 🟡 In Progress / Upcoming
- **SQL Editor** — Monaco Editor integration with schema-aware SQL autocomplete, query history, saved queries
- **Query Editor (Polish)** — SQL autocomplete (keywords, tables, and per-table columns), query history UI dropdown, saved queries
- **SSH/SSL Runtime** — SSH tunnel via `ssh2` crate, SSL/TLS config passed to `sqlx`/`tokio-postgres`
- **Inline Cell Editing** — Edit cells directly in the data grid
- **Data Import** — CSV, JSON import with column mapping
@@ -238,6 +245,7 @@ gridline/
- **Deeper PostgreSQL** — Indexes, constraints, materialized views, stored procedure view, user/role management
- **Collaboration** — Team workspaces, shared connections, query sharing
- **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.
---
+38
View File
@@ -9,6 +9,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@fontsource/outfit": "^5.3.0",
"@fontsource/space-mono": "^5.3.0",
"@monaco-editor/react": "^4.7.0",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-virtual": "^3.14.8",
"@tauri-apps/api": "^2",
@@ -17,11 +18,14 @@
"@tauri-apps/plugin-opener": "^2",
"@xyflow/react": "^12.11.2",
"dagre": "^0.8.5",
"html-to-image": "^1.11.13",
"lucide-react": "^1.26.0",
"monaco-editor": "^0.56.0",
"motion": "^12.42.2",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"simple-icons": "^16.27.1",
"sql-formatter": "^15.8.2",
"tauri-plugin-keyring-store-api": "^0.2.0",
"zustand": "^5.0.14",
},
@@ -180,6 +184,10 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@monaco-editor/loader": ["@monaco-editor/loader@1.7.0", "", { "dependencies": { "state-local": "^1.0.6" } }, "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA=="],
"@monaco-editor/react": ["@monaco-editor/react@4.7.0", "", { "dependencies": { "@monaco-editor/loader": "^1.5.0" }, "peerDependencies": { "monaco-editor": ">= 0.25.0 < 1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.2", "", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="],
@@ -342,6 +350,8 @@
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
"@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="],
@@ -366,6 +376,8 @@
"ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
@@ -382,6 +394,8 @@
"classcat": ["classcat@5.0.5", "", {}, "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w=="],
"commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="],
@@ -420,8 +434,12 @@
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"discontinuous-range": ["discontinuous-range@1.0.0", "", {}, "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ=="],
"dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
"dompurify": ["dompurify@3.4.8", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ=="],
"electron-to-chromium": ["electron-to-chromium@1.5.396", "", {}, "sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ=="],
"enhanced-resolve": ["enhanced-resolve@5.24.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ=="],
@@ -452,6 +470,8 @@
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
"html-to-image": ["html-to-image@1.11.13", "", {}, "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg=="],
"indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
@@ -500,10 +520,16 @@
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"marked": ["marked@14.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="],
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
"min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
"monaco-editor": ["monaco-editor@0.56.0", "", { "dependencies": { "dompurify": "3.4.8", "marked": "14.0.0" } }, "sha512-sXboRm3BeBeLm938eaiyLMe0OxzfXIlZvbv4ir/jVgQy1zDhWjgmny0WoN45fuDKhCCQsYMbBJrv/A6jd8aCUg=="],
"moo": ["moo@0.5.3", "", {}, "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA=="],
"motion": ["motion@12.42.2", "", { "dependencies": { "framer-motion": "^12.42.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-Atvv11yUKIid41cVrRBDVX5m8tF8kNpExRSlbpt6APClhDjtwQssgFHhQzejxw7/7YYbjHSPKBVbHo05BuJT5Q=="],
"motion-dom": ["motion-dom@12.42.2", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA=="],
@@ -514,6 +540,8 @@
"nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="],
"nearley": ["nearley@2.20.1", "", { "dependencies": { "commander": "^2.19.0", "moo": "^0.5.0", "railroad-diagrams": "^1.0.0", "randexp": "0.4.6" }, "bin": { "nearleyc": "bin/nearleyc.js", "nearley-test": "bin/nearley-test.js", "nearley-unparse": "bin/nearley-unparse.js", "nearley-railroad": "bin/nearley-railroad.js" } }, "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ=="],
"node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="],
"obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="],
@@ -532,6 +560,10 @@
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"railroad-diagrams": ["railroad-diagrams@1.0.0", "", {}, "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A=="],
"randexp": ["randexp@0.4.6", "", { "dependencies": { "discontinuous-range": "1.0.0", "ret": "~0.1.10" } }, "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ=="],
"react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
"react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
@@ -544,6 +576,8 @@
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"ret": ["ret@0.1.15", "", {}, "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg=="],
"rollup": ["rollup@4.62.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="],
"saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
@@ -558,8 +592,12 @@
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"sql-formatter": ["sql-formatter@15.8.2", "", { "dependencies": { "argparse": "^2.0.1", "nearley": "^2.20.1" }, "bin": { "sql-formatter": "bin/sql-formatter-cli.cjs" } }, "sha512-kTYRg5FIcvsDtYUG2Qn9pYT6xKwiLJN5TTIvc5Mur6hIg4pSfdpHu8Yyu5bqESLHnVM3mXzD446cb2+uEaKZXg=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
"state-local": ["state-local@1.0.7", "", {}, "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w=="],
"std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="],
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
+4
View File
@@ -18,6 +18,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@fontsource/outfit": "^5.3.0",
"@fontsource/space-mono": "^5.3.0",
"@monaco-editor/react": "^4.7.0",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-virtual": "^3.14.8",
"@tauri-apps/api": "^2",
@@ -26,11 +27,14 @@
"@tauri-apps/plugin-opener": "^2",
"@xyflow/react": "^12.11.2",
"dagre": "^0.8.5",
"html-to-image": "^1.11.13",
"lucide-react": "^1.26.0",
"monaco-editor": "^0.56.0",
"motion": "^12.42.2",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"simple-icons": "^16.27.1",
"sql-formatter": "^15.8.2",
"tauri-plugin-keyring-store-api": "^0.2.0",
"zustand": "^5.0.14"
},
+4
View File
@@ -880,6 +880,7 @@ ORDER BY c.ordinal_position"#;
total_rows,
page: p,
page_size: ps,
execution_time_ms: None,
})
}
Some(crate::db::pool::DbHandle::Sqlite(conn)) => {
@@ -990,6 +991,7 @@ ORDER BY c.ordinal_position"#;
total_rows,
page: p,
page_size: ps,
execution_time_ms: None,
})
}
None => Err("Connection not found".to_string()),
@@ -1097,6 +1099,7 @@ ORDER BY c.ordinal_position"#;
total_rows: 1,
page: 1,
page_size: 1,
execution_time_ms: None,
})
}
Some(crate::db::pool::DbHandle::Sqlite(conn)) => {
@@ -1178,6 +1181,7 @@ ORDER BY c.ordinal_position"#;
total_rows: 1,
page: 1,
page_size: 1,
execution_time_ms: None,
})
}
None => Err("Connection not found".to_string()),
+1
View File
@@ -10,3 +10,4 @@ pub mod keychain;
pub mod demo;
pub mod backup;
pub mod schema_graph;
pub mod query;
+748
View File
@@ -0,0 +1,748 @@
//! Arbitrary SQL query execution with subquery-based pagination and
//! queryhistory recording.
//!
//! Architecture:
//! 1. Subquery wrapping is attempted first:
//! `SELECT * FROM (user_query) AS _gridline_data LIMIT x OFFSET y`
//! `SELECT COUNT(*) FROM (user_query) AS _gridline_cnt`
//! 2. If wrapping fails (CTEs, multistatement), fall back to raw
//! execution with clientside slicing.
//! 3. Every query is recorded in the local `query_history` table.
use crate::db::pool::DbHandle;
use crate::models::db_viewer::{ColumnInfo, QueryResult};
use serde::{Deserialize, Serialize};
use std::time::Instant;
use tauri::State;
use uuid::Uuid;
// ---------------------------------------------------------------------------
// QueryHistoryEntry
// ---------------------------------------------------------------------------
/// A single record in the local `query_history` table.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueryHistoryEntry {
pub id: String,
pub connection_id: String,
pub query_text: String,
pub execution_time_ms: Option<i64>,
pub row_count: Option<i64>,
/// `"success"` or `"error"`.
pub status: String,
pub error_message: Option<String>,
pub executed_at: String,
}
// ---------------------------------------------------------------------------
// Core executor (not a Tauri command itself — called by the command wrapper)
// ---------------------------------------------------------------------------
/// Execute arbitrary SQL on PostgreSQL or SQLite with subquerybased
/// pagination and automatic fallback to clientside slicing.
///
/// Returns `(QueryResult, Option<QueryHistoryEntry>)` so the caller can
/// write the history record through the store.
pub(crate) async fn execute_query_inner(
pool_manager: &mut crate::db::pool::ConnectionPoolManager,
db_store: &std::sync::Mutex<crate::store::Store>,
connection_id: &str,
query: &str,
page: i64,
page_size: i64,
) -> Result<QueryResult, String> {
let start = Instant::now();
let history_id = Uuid::new_v4().to_string();
// Try subquery-wrapped execution first; fall back to raw on failure.
let mut result = match pool_manager.get(connection_id) {
Some(DbHandle::Postgresql(client, _)) => {
execute_pg_query(client, query, page, page_size).await
}
Some(DbHandle::Sqlite(conn)) => {
execute_sqlite_query(conn, query, page, page_size)
}
None => {
let elapsed = start.elapsed().as_millis() as i64;
let err = "Connection not found".to_string();
insert_history(
db_store,
&history_id,
connection_id,
query,
Some(elapsed),
None,
"error",
Some(&err),
);
return Err(err);
}
};
let elapsed = start.elapsed().as_millis() as i64;
// Attach server-side execution time to the returned result so the UI can
// show "time taken" for query runs.
if let Ok(qr) = &mut result {
qr.execution_time_ms = Some(elapsed);
}
match &result {
Ok(qr) => {
insert_history(
db_store,
&history_id,
connection_id,
query,
Some(elapsed),
Some(qr.rows.len() as i64),
"success",
None,
);
}
Err(e) => {
insert_history(
db_store,
&history_id,
connection_id,
query,
Some(elapsed),
None,
"error",
Some(e),
);
}
}
result
}
/// Helper: insert a query_history row through the store, swallowing errors.
fn insert_history(
db_store: &std::sync::Mutex<crate::store::Store>,
id: &str,
connection_id: &str,
query_text: &str,
execution_time_ms: Option<i64>,
row_count: Option<i64>,
status: &str,
error_message: Option<&str>,
) {
if let Ok(store) = db_store.lock() {
let _ = store.insert_query_history(
id,
connection_id,
query_text,
execution_time_ms,
row_count,
status,
error_message,
);
}
}
// ---------------------------------------------------------------------------
// PostgreSQL execution
// ---------------------------------------------------------------------------
/// Try subquerywrapped pagination for PostgreSQL. Falls back to raw
/// execution via `simple_query` if wrapping produces a parse error.
async fn execute_pg_query(
client: &tokio_postgres::Client,
query: &str,
page: i64,
page_size: i64,
) -> Result<QueryResult, String> {
let trimmed = query.trim();
if trimmed.is_empty() {
return Err("Query cannot be empty".to_string());
}
let off = (page.saturating_sub(1).max(0)) * page_size;
// Attempt subquery wrapping.
let wrapped_data = format!(
"SELECT * FROM ({}) AS _gridline_data LIMIT $1 OFFSET $2",
trimmed
);
let wrapped_count = format!(
"SELECT COUNT(*) FROM ({}) AS _gridline_cnt",
trimmed
);
// Try the wrapped count query first — if this fails we fall back to raw.
let total_rows: i64 = match client
.query_one(&wrapped_count, &[])
.await
{
Ok(row) => row.get::<_, i64>(0),
Err(_) => {
// Wrapping failed — fall back to raw execution.
return execute_pg_raw(client, trimmed, page, page_size, off).await;
}
};
// Now execute the wrapped data query.
let data_rows = match client.query(&wrapped_data, &[&page_size, &off]).await {
Ok(rows) => rows,
Err(_) => {
return execute_pg_raw(client, trimmed, page, page_size, off).await;
}
};
// Build column info from the data rows.
let columns: Vec<ColumnInfo> = match data_rows.first() {
Some(first) => first
.columns()
.iter()
.map(|c| ColumnInfo {
name: c.name().to_string(),
data_type: format!("{:?}", c.type_()),
is_nullable: true,
is_pk: false,
is_fk: false,
fk_ref: None,
default_value: None,
})
.collect(),
None => {
// No rows — fall back to raw execution which handles
// column metadata via simple_query.
return execute_pg_raw(client, trimmed, page, page_size, off).await;
}
};
// Convert rows to JSON.
let rows: Vec<Vec<serde_json::Value>> = data_rows
.iter()
.map(|row| {
(0..row.len())
.map(|i| crate::commands::db_viewer::pg_value_to_json(row, i))
.collect()
})
.collect();
Ok(QueryResult {
columns,
rows,
total_rows,
page,
page_size,
execution_time_ms: None,
})
}
// ---------------------------------------------------------------------------
// PostgreSQL raw fallback (simple_query)
// ---------------------------------------------------------------------------
/// Execute a raw SQL string via `simple_query`, collecting all result rows
/// and slicing them on the client side for pagination.
async fn execute_pg_raw(
client: &tokio_postgres::Client,
query: &str,
page: i64,
page_size: i64,
offset: i64,
) -> Result<QueryResult, String> {
let messages = client
.simple_query(query)
.await
.map_err(|e| crate::commands::db_viewer::pg_error_message(&e))?;
let mut columns: Vec<ColumnInfo> = Vec::new();
let mut all_rows: Vec<Vec<serde_json::Value>> = Vec::new();
let mut saw_columns = false;
for msg in messages {
match msg {
tokio_postgres::SimpleQueryMessage::Row(row) => {
if !saw_columns {
columns = row
.columns()
.iter()
.map(|c| ColumnInfo {
name: c.name().to_string(),
data_type: "text".to_string(),
is_nullable: true,
is_pk: false,
is_fk: false,
fk_ref: None,
default_value: None,
})
.collect();
saw_columns = true;
}
let values: Vec<serde_json::Value> = (0..row.len())
.map(|i| {
// simple_query protocol returns everything as strings;
// try_get with just the index returns Option<&str>.
match row.try_get::<usize>(i) {
Ok(Some(s)) => serde_json::Value::String(s.to_string()),
Ok(None) => serde_json::Value::Null,
Err(_) => serde_json::Value::Null,
}
})
.collect();
all_rows.push(values);
}
tokio_postgres::SimpleQueryMessage::CommandComplete(..) => {
// DML statements like INSERT, UPDATE, DELETE, etc.
// Return empty result with row count from the tag.
}
_ => {}
}
}
let total_rows = all_rows.len() as i64;
let uoffset = offset as usize;
let ulimit = page_size as usize;
let rows: Vec<Vec<serde_json::Value>> = if uoffset < all_rows.len() {
all_rows
.into_iter()
.skip(uoffset)
.take(ulimit)
.collect()
} else {
Vec::new()
};
Ok(QueryResult {
columns,
rows,
total_rows,
page,
page_size,
execution_time_ms: None,
})
}
// ---------------------------------------------------------------------------
// SQLite execution
// ---------------------------------------------------------------------------
/// Try subquerywrapped pagination for SQLite. Falls back to raw execution
/// if wrapping produces a parse error.
fn execute_sqlite_query(
conn: &rusqlite::Connection,
query: &str,
page: i64,
page_size: i64,
) -> Result<QueryResult, String> {
let trimmed = query.trim();
if trimmed.is_empty() {
return Err("Query cannot be empty".to_string());
}
let off = (page.saturating_sub(1).max(0)) * page_size;
// Attempt subquery wrapping.
let wrapped_data = format!(
"SELECT * FROM ({}) AS _gridline_data LIMIT {} OFFSET {}",
trimmed, page_size, off
);
let wrapped_count = format!(
"SELECT COUNT(*) FROM ({}) AS _gridline_cnt",
trimmed
);
// Try the wrapped count query first.
let total_rows: i64 = match conn.query_row(&wrapped_count, [], |row| row.get::<_, i64>(0)) {
Ok(n) => n,
Err(_) => {
// Wrapping failed — fall back to raw execution.
return execute_sqlite_raw(conn, trimmed, page, page_size, off);
}
};
// Execute the wrapped data query.
let (columns, all_rows) = match execute_sqlite_with_query(conn, &wrapped_data) {
Ok(result) => result,
Err(_) => {
return execute_sqlite_raw(conn, trimmed, page, page_size, off);
}
};
Ok(QueryResult {
columns,
rows: all_rows,
total_rows,
page,
page_size,
execution_time_ms: None,
})
}
/// Execute a SQL string on SQLite and return `(columns, rows)`.
fn execute_sqlite_with_query(
conn: &rusqlite::Connection,
sql: &str,
) -> Result<(Vec<ColumnInfo>, Vec<Vec<serde_json::Value>>), String> {
let mut stmt = conn.prepare(sql).map_err(|e| e.to_string())?;
let columns: Vec<ColumnInfo> = (0..stmt.column_count())
.map(|i| {
let name = stmt.column_name(i).unwrap_or("?").to_string();
ColumnInfo {
name,
data_type: "TEXT".to_string(),
is_nullable: true,
is_pk: false,
is_fk: false,
fk_ref: None,
default_value: None,
}
})
.collect();
let col_count = stmt.column_count();
let rows: Vec<Vec<serde_json::Value>> = stmt
.query_map([], |row| {
let mut vals = Vec::with_capacity(col_count);
for i in 0..col_count {
vals.push(sqlite_value_to_json(row, i));
}
Ok(vals)
})
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
Ok((columns, rows))
}
// ---------------------------------------------------------------------------
// SQLite raw fallback
// ---------------------------------------------------------------------------
/// Execute raw SQL on SQLite without subquery wrapping, paginating
/// clientside.
fn execute_sqlite_raw(
conn: &rusqlite::Connection,
query: &str,
page: i64,
page_size: i64,
offset: i64,
) -> Result<QueryResult, String> {
let (columns, all_rows) = execute_sqlite_with_query(conn, query)?;
let total_rows = all_rows.len() as i64;
let uoffset = offset as usize;
let ulimit = page_size as usize;
let rows: Vec<Vec<serde_json::Value>> = if uoffset < all_rows.len() {
all_rows
.into_iter()
.skip(uoffset)
.take(ulimit)
.collect()
} else {
Vec::new()
};
Ok(QueryResult {
columns,
rows,
total_rows,
page,
page_size,
execution_time_ms: None,
})
}
// ---------------------------------------------------------------------------
// Value conversion helpers
// ---------------------------------------------------------------------------
/// Convert a `rusqlite::Row` cell to `serde_json::Value`.
fn sqlite_value_to_json(row: &rusqlite::Row, i: usize) -> serde_json::Value {
use rusqlite::types::ValueRef;
match row.get_ref(i) {
Ok(ValueRef::Null) => serde_json::Value::Null,
Ok(ValueRef::Integer(v)) => serde_json::json!(v),
Ok(ValueRef::Real(v)) => serde_json::json!(v),
Ok(ValueRef::Text(v)) => {
serde_json::Value::String(String::from_utf8_lossy(v).to_string())
}
Ok(ValueRef::Blob(v)) => {
serde_json::Value::String(format!("[{}B blob]", v.len()))
}
Err(_) => serde_json::Value::Null,
}
}
// ---------------------------------------------------------------------------
// Query history commands
// ---------------------------------------------------------------------------
pub(crate) fn get_query_history_inner(
db_store: &std::sync::Mutex<crate::store::Store>,
connection_id: Option<&str>,
limit: i64,
offset: i64,
) -> Result<Vec<QueryHistoryEntry>, String> {
let store = db_store.lock().map_err(|e| e.to_string())?;
store.get_query_history(connection_id, limit, offset)
}
pub(crate) fn clear_query_history_inner(
db_store: &std::sync::Mutex<crate::store::Store>,
connection_id: Option<&str>,
) -> Result<(), String> {
let store = db_store.lock().map_err(|e| e.to_string())?;
store.clear_query_history(connection_id)
}
// ---------------------------------------------------------------------------
// Tauri commands
// ---------------------------------------------------------------------------
#[tauri::command]
pub async fn execute_query(
connection_id: String,
query: String,
page: Option<i64>,
page_size: Option<i64>,
state: State<'_, crate::AppState>,
) -> Result<QueryResult, String> {
let p = page.unwrap_or(1);
let ps = page_size.unwrap_or(50);
let mut pm = state.pool_manager.lock().await;
execute_query_inner(&mut pm, &state.db_store, &connection_id, &query, p, ps).await
}
#[tauri::command]
pub async fn get_query_history(
connection_id: Option<String>,
limit: Option<i64>,
offset: Option<i64>,
state: State<'_, crate::AppState>,
) -> Result<Vec<QueryHistoryEntry>, String> {
let l = limit.unwrap_or(50);
let o = offset.unwrap_or(0);
let store = state.db_store.lock().map_err(|e| e.to_string())?;
store.get_query_history(connection_id.as_deref(), l, o)
}
#[tauri::command]
pub async fn clear_query_history(
connection_id: Option<String>,
state: State<'_, crate::AppState>,
) -> Result<(), String> {
let store = state.db_store.lock().map_err(|e| e.to_string())?;
store.clear_query_history(connection_id.as_deref())
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::store::Store;
use rusqlite::Connection as SqliteConnection;
use std::sync::Mutex;
/// Create an inmemory Store with all migrations applied.
fn test_store() -> Mutex<Store> {
let conn = SqliteConnection::open_in_memory().unwrap();
crate::store::migrations::run_migrations(&conn).unwrap();
// Insert a placeholder connection so FK constraints are satisfied.
conn.execute(
"INSERT INTO connections (id, name, db_type, host, port, created_at, updated_at)
VALUES ('test-conn', 'test', 'sqlite', ':memory:', NULL, datetime('now'), datetime('now'))",
[],
)
.unwrap();
Mutex::new(Store::from_connection(conn))
}
/// Open an inmemory SQLite database and return a DbHandle.
fn test_sqlite_handle() -> DbHandle {
let conn = SqliteConnection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT);
INSERT INTO users VALUES (1, 'Alice', 'alice@example.com');
INSERT INTO users VALUES (2, 'Bob', 'bob@example.com');
INSERT INTO users VALUES (3, 'Charlie', 'charlie@example.com');",
)
.unwrap();
DbHandle::Sqlite(conn)
}
/// Open an inmemory SQLite database for CTE testing.
fn test_sqlite_cte_handle() -> DbHandle {
let conn = SqliteConnection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE TABLE items (id INTEGER PRIMARY KEY, val INTEGER);
INSERT INTO items VALUES (1, 10);
INSERT INTO items VALUES (2, 20);
INSERT INTO items VALUES (3, 30);
INSERT INTO items VALUES (4, 40);
INSERT INTO items VALUES (5, 50);",
)
.unwrap();
DbHandle::Sqlite(conn)
}
// ------------------------------------------------------------------
// Test 1: Basic SELECT execution with pagination
// ------------------------------------------------------------------
#[test]
fn basic_select_with_pagination() {
let _store = test_store();
let conn = test_sqlite_handle();
let (columns, rows) =
execute_sqlite_with_query(&unwrap_sqlite(&conn), "SELECT * FROM users ORDER BY id")
.unwrap();
assert_eq!(columns.len(), 3);
assert_eq!(columns[0].name, "id");
assert_eq!(rows.len(), 3);
// Verify row values
assert_eq!(rows[0][0], serde_json::json!(1));
assert_eq!(rows[0][1], serde_json::json!("Alice"));
assert_eq!(rows[1][0], serde_json::json!(2));
}
// ------------------------------------------------------------------
// Test 2: Subquery wrapping produces correct LIMIT/OFFSET
// ------------------------------------------------------------------
#[test]
fn subquery_wrapping_paginates_correctly() {
let _store = test_store();
let conn = test_sqlite_handle();
// Page 1: 2 rows
let result =
execute_sqlite_query(&unwrap_sqlite(&conn), "SELECT * FROM users ORDER BY id", 1, 2)
.unwrap();
assert_eq!(result.total_rows, 3);
assert_eq!(result.rows.len(), 2);
assert_eq!(result.rows[0][1], serde_json::json!("Alice"));
assert_eq!(result.rows[1][1], serde_json::json!("Bob"));
assert_eq!(result.page, 1);
assert_eq!(result.page_size, 2);
// Page 2: 1 row
let result2 =
execute_sqlite_query(&unwrap_sqlite(&conn), "SELECT * FROM users ORDER BY id", 2, 2)
.unwrap();
assert_eq!(result2.total_rows, 3);
assert_eq!(result2.rows.len(), 1);
assert_eq!(result2.rows[0][1], serde_json::json!("Charlie"));
}
// ------------------------------------------------------------------
// Test 3: EXPLAIN falls back to raw execution with clientside pagination
// ------------------------------------------------------------------
#[test]
fn cte_falls_back_to_raw_execution() {
let _store = test_store();
let conn = test_sqlite_cte_handle();
// EXPLAIN cannot be wrapped in a subquery:
// SELECT * FROM (EXPLAIN SELECT ...) is a syntax error.
// This forces the fallback to raw execution.
let query = "EXPLAIN SELECT * FROM items WHERE val > 20";
let result = execute_sqlite_query(&unwrap_sqlite(&conn), query, 1, 10).unwrap();
// Should fall back to raw — all rows fetched, clientslice.
// EXPLAIN returns rows (addr, opcode, p1, p2, p3, p4, p5, comment).
assert!(result.total_rows > 0, "EXPLAIN should return rows");
assert!(result.rows.len() <= 10, "page_size should limit rows");
assert_eq!(result.columns.len(), 8, "EXPLAIN has 8 columns");
}
// ------------------------------------------------------------------
// Test 4: Query history is recorded
// ------------------------------------------------------------------
#[test]
fn query_history_is_recorded() {
let store = test_store();
// Directly insert a history entry and read it back.
let entry_id = "hist-001";
{
let s = store.lock().unwrap();
s.insert_query_history(
entry_id,
"test-conn",
"SELECT 1",
Some(42),
Some(1),
"success",
None,
)
.unwrap();
}
// Read it back.
{
let s = store.lock().unwrap();
let history = s.get_query_history(Some("test-conn"), 10, 0).unwrap();
assert_eq!(history.len(), 1);
assert_eq!(history[0].id, "hist-001");
assert_eq!(history[0].connection_id, "test-conn");
assert_eq!(history[0].query_text, "SELECT 1");
assert_eq!(history[0].execution_time_ms, Some(42));
assert_eq!(history[0].row_count, Some(1));
assert_eq!(history[0].status, "success");
assert_eq!(history[0].error_message, None);
}
// Insert an error entry.
{
let s = store.lock().unwrap();
s.insert_query_history(
"hist-002",
"test-conn",
"SELECT invalid",
Some(5),
None,
"error",
Some("syntax error"),
)
.unwrap();
}
// Read back with limit.
{
let s = store.lock().unwrap();
let history = s.get_query_history(Some("test-conn"), 1, 0).unwrap();
assert_eq!(history.len(), 1);
// Most recent first (DESC order)
assert_eq!(history[0].id, "hist-002");
}
// Clear history for connection.
{
let s = store.lock().unwrap();
s.clear_query_history(Some("test-conn")).unwrap();
let history = s.get_query_history(Some("test-conn"), 10, 0).unwrap();
assert_eq!(history.len(), 0);
}
}
// ------------------------------------------------------------------
// Helper: unwrap a DbHandle::Sqlite to get the connection reference
// ------------------------------------------------------------------
fn unwrap_sqlite(handle: &DbHandle) -> &SqliteConnection {
match handle {
DbHandle::Sqlite(conn) => conn,
_ => panic!("Expected Sqlite handle"),
}
}
}
+4 -1
View File
@@ -19,7 +19,7 @@ pub struct AppState {
pub ssh_manager: StdMutex<SshTunnelManager>,
}
use commands::{connections, db_viewer, folders, tags, settings, import_export, keychain, demo, backup, schema_graph};
use commands::{connections, db_viewer, folders, tags, settings, import_export, keychain, demo, backup, schema_graph, query};
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
#[tauri::command]
@@ -95,6 +95,9 @@ pub fn run() {
backup::pg_restore,
backup::db_sync,
schema_graph::get_schema_graph,
query::execute_query,
query::get_query_history,
query::clear_query_history,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+2
View File
@@ -42,6 +42,7 @@ pub struct QueryResult {
pub total_rows: i64,
pub page: i64,
pub page_size: i64,
pub execution_time_ms: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -216,6 +217,7 @@ mod tests {
total_rows: 0,
page: 1,
page_size: 100,
execution_time_ms: None,
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains(r#""rows":[]"#));
+76 -1
View File
@@ -169,6 +169,31 @@ pub fn run_migrations(conn: &Connection) -> Result<(), String> {
.map_err(|e| e.to_string())?;
}
// v5: query_history
if current_ver < 5 {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS query_history (
id TEXT PRIMARY KEY,
connection_id TEXT NOT NULL,
query_text TEXT NOT NULL,
execution_time_ms INTEGER,
row_count INTEGER,
status TEXT NOT NULL CHECK(status IN ('success', 'error')),
error_message TEXT,
executed_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (connection_id) REFERENCES connections(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_query_history_connection
ON query_history(connection_id, executed_at DESC);"
).map_err(|e| e.to_string())?;
conn.execute(
"INSERT INTO schema_version (version) VALUES (5)",
[],
)
.map_err(|e| e.to_string())?;
}
Ok(())
}
@@ -219,6 +244,56 @@ mod tests {
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM schema_version", [], |row| row.get(0))
.unwrap();
assert_eq!(count, 3);
assert_eq!(count, 4);
}
#[test]
fn v5_creates_query_history_table() {
let conn = Connection::open_in_memory().unwrap();
run_migrations(&conn).unwrap();
// Verify the table exists
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM query_history", [], |r| r.get(0))
.unwrap();
assert_eq!(count, 0);
// Verify columns via PRAGMA
let columns: Vec<String> = {
let mut stmt = conn.prepare("PRAGMA table_info(query_history)").unwrap();
let rows = stmt
.query_map([], |row| row.get::<_, String>(1))
.unwrap();
rows.filter_map(|r| r.ok()).collect()
};
assert!(columns.contains(&"id".to_string()));
assert!(columns.contains(&"connection_id".to_string()));
assert!(columns.contains(&"query_text".to_string()));
assert!(columns.contains(&"execution_time_ms".to_string()));
assert!(columns.contains(&"row_count".to_string()));
assert!(columns.contains(&"status".to_string()));
assert!(columns.contains(&"error_message".to_string()));
assert!(columns.contains(&"executed_at".to_string()));
}
#[test]
fn query_history_cascades_on_connection_delete() {
let conn = Connection::open_in_memory().unwrap();
run_migrations(&conn).unwrap();
// Insert a connection
let conn_id = "test-conn-id";
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();
// Insert query history entry
conn.execute(
"INSERT INTO query_history (id, connection_id, query_text, status, executed_at) VALUES ('qh1', ?1, 'SELECT 1', 'success', datetime('now'))",
rusqlite::params![conn_id],
).unwrap();
// Delete connection — should cascade
conn.execute("DELETE FROM connections WHERE id = ?1", rusqlite::params![conn_id]).unwrap();
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM query_history WHERE connection_id = ?1", rusqlite::params![conn_id], |r| r.get(0))
.unwrap();
assert_eq!(count, 0);
}
}
+78 -1
View File
@@ -447,6 +447,83 @@ impl Store {
.map_err(|e| e.to_string())?;
Ok(())
}
/// Insert a row into the `query_history` table.
pub fn insert_query_history(
&self,
id: &str,
connection_id: &str,
query_text: &str,
execution_time_ms: Option<i64>,
row_count: Option<i64>,
status: &str,
error_message: Option<&str>,
) -> Result<(), String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
let now = Self::now();
conn.execute(
"INSERT INTO query_history (id, connection_id, query_text, execution_time_ms, row_count, status, error_message, executed_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![id, connection_id, query_text, execution_time_ms, row_count, status, error_message, now],
)
.map_err(|e| e.to_string())?;
Ok(())
}
/// Fetch query history rows, optionally filtered by `connection_id`.
/// Returns results ordered by `executed_at DESC`.
pub fn get_query_history(
&self,
connection_id: Option<&str>,
limit: i64,
offset: i64,
) -> Result<Vec<crate::commands::query::QueryHistoryEntry>, String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
let (sql, params): (String, Vec<Box<dyn rusqlite::types::ToSql>>) =
if let Some(cid) = connection_id {
(
"SELECT id, connection_id, query_text, execution_time_ms, row_count, status, error_message, executed_at FROM query_history WHERE connection_id = ?1 ORDER BY executed_at DESC LIMIT ?2 OFFSET ?3".to_string(),
vec![Box::new(cid.to_string()), Box::new(limit), Box::new(offset)],
)
} else {
(
"SELECT id, connection_id, query_text, execution_time_ms, row_count, status, error_message, executed_at FROM query_history ORDER BY executed_at DESC LIMIT ?1 OFFSET ?2".to_string(),
vec![Box::new(limit), Box::new(offset)],
)
};
let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
let refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let rows = stmt
.query_map(rusqlite::params_from_iter(&refs), |row| {
Ok(crate::commands::query::QueryHistoryEntry {
id: row.get(0)?,
connection_id: row.get(1)?,
query_text: row.get(2)?,
execution_time_ms: row.get(3)?,
row_count: row.get(4)?,
status: row.get(5)?,
error_message: row.get(6)?,
executed_at: row.get(7)?,
})
})
.map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
}
/// Delete all query history rows, optionally filtered by `connection_id`.
pub fn clear_query_history(&self, connection_id: Option<&str>) -> Result<(), String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
if let Some(cid) = connection_id {
conn.execute(
"DELETE FROM query_history WHERE connection_id = ?1",
params![cid],
)
.map_err(|e| e.to_string())?;
} else {
conn.execute("DELETE FROM query_history", [])
.map_err(|e| e.to_string())?;
}
Ok(())
}
}
#[cfg(test)]
@@ -690,7 +767,7 @@ mod tests {
#[test]
fn ssh_ssl_fields_persist_and_retrieve() {
let store = fresh_store();
let conn = store
let _conn = store
.create_connection(ConnectionInput {
name: "SSH-Tunnel-DB".into(),
db_type: "postgresql".into(),
@@ -1,13 +1,14 @@
import { describe, it, expect, vi } 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 { ConnectionGrid } from "./ConnectionGrid";
import type { Connection, Folder } from "../../lib/types";
import { useUiStore } from "../../stores/uiStore";
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: "",
created_at: "", updated_at: "", environment: null,
});
const folders: Folder[] = [
@@ -17,6 +18,10 @@ const folders: Folder[] = [
];
describe("ConnectionGrid", () => {
beforeEach(() => {
useUiStore.setState({ activeTagIds: [], activeDbTypes: [], activeEnvironment: null });
});
it("renders empty state when no connections and no folders", () => {
render(<ConnectionGrid connections={[]} tags={[]} />);
expect(screen.getByText(/no connections yet/i)).toBeInTheDocument();
@@ -66,4 +71,87 @@ describe("ConnectionGrid", () => {
expect(screen.getByText("Work")).toBeInTheDocument();
expect(screen.getByText("Personal")).toBeInTheDocument();
});
it("hides folders that match no tags and contain no matching connections", () => {
useUiStore.setState({ activeTagIds: ["t1"] });
const taggedFolders: Folder[] = [
{ id: "f1", name: "Tagged Folder", parent_id: null, tag_ids: ["t1"], created_at: "", updated_at: "" },
{ id: "f2", name: "Untagged Folder", parent_id: null, tag_ids: [], created_at: "", updated_at: "" },
];
const tags: Tag[] = [{ id: "t1", name: "prod", color: "#f00", created_at: "" }];
render(<ConnectionGrid connections={[]} tags={tags} folders={taggedFolders} />);
expect(screen.getByText("Tagged Folder")).toBeInTheDocument();
expect(screen.queryByText("Untagged Folder")).not.toBeInTheDocument();
});
it("shows folder when it contains a matching connection even if untagged", () => {
useUiStore.setState({ activeTagIds: ["t1"] });
const foldersWithConn: Folder[] = [
{ id: "f1", name: "Parent", parent_id: null, tag_ids: [], created_at: "", updated_at: "" },
];
const conns = [makeConn("c1", "f1")];
conns[0] = { ...conns[0], tag_ids: ["t1"] };
const tags: Tag[] = [{ id: "t1", name: "prod", color: "#f00", created_at: "" }];
render(<ConnectionGrid connections={conns} tags={tags} folders={foldersWithConn} />);
expect(screen.getByText("Parent")).toBeInTheDocument();
});
it("shows all folders when no tag filter is active", () => {
useUiStore.setState({ activeTagIds: [] });
render(<ConnectionGrid connections={[]} tags={[]} folders={folders} />);
expect(screen.getByText("Work")).toBeInTheDocument();
expect(screen.getByText("Personal")).toBeInTheDocument();
});
it("hides folders whose connections don't match the DB type filter", () => {
useUiStore.setState({ activeDbTypes: ["sqlite"] });
const typedFolders: Folder[] = [
{ id: "f1", name: "PG Folder", parent_id: null, tag_ids: [], created_at: "", updated_at: "" },
{ id: "f2", name: "SQLite Folder", parent_id: null, tag_ids: [], created_at: "", updated_at: "" },
];
const conns: Connection[] = [
{ ...makeConn("c1", "f1"), db_type: "postgresql" },
{ ...makeConn("c2", "f2"), db_type: "sqlite" },
];
render(<ConnectionGrid connections={conns} tags={[]} folders={typedFolders} />);
expect(screen.queryByText("PG Folder")).not.toBeInTheDocument();
expect(screen.getByText("SQLite Folder")).toBeInTheDocument();
});
it("hides folders whose connections don't match the environment filter", () => {
useUiStore.setState({ activeEnvironment: "production" });
const envFolders: Folder[] = [
{ id: "f1", name: "Prod Folder", parent_id: null, tag_ids: [], created_at: "", updated_at: "" },
{ id: "f2", name: "Dev Folder", parent_id: null, tag_ids: [], created_at: "", updated_at: "" },
];
const conns: Connection[] = [
{ ...makeConn("c1", "f1"), environment: "production" },
{ ...makeConn("c2", "f2"), environment: "development" },
];
render(<ConnectionGrid connections={conns} tags={[]} folders={envFolders} />);
expect(screen.getByText("Prod Folder")).toBeInTheDocument();
expect(screen.queryByText("Dev Folder")).not.toBeInTheDocument();
});
it("shows search results from all folders as if at root", () => {
useUiStore.setState({ activeFolderId: "f1", searchQuery: "conn" });
const searchFolders: Folder[] = [
{ id: "f1", name: "Folder 1", parent_id: null, tag_ids: [], created_at: "", updated_at: "" },
{ id: "f2", name: "Folder 2", parent_id: null, tag_ids: [], created_at: "", updated_at: "" },
];
const conns = [makeConn("c1", "f1"), makeConn("c2", "f2")];
render(
<ConnectionGrid
connections={conns}
tags={[]}
folders={searchFolders}
activeFolderId="f1"
hasSearch
/>
);
expect(screen.getByText("Conn c2")).toBeInTheDocument();
expect(screen.queryByText("Folder 1")).not.toBeInTheDocument();
expect(screen.queryByText("Folder 2")).not.toBeInTheDocument();
expect(screen.getByText("Showing Search Results")).toBeInTheDocument();
});
});
+37 -6
View File
@@ -1,5 +1,4 @@
import type { Connection, Folder, Tag } from "../../lib/types";
import { useMemo } from "react";
import { Folder as FolderIcon, Check, Pencil, Trash2 } from "lucide-react";
import { useDroppable } from "@dnd-kit/core";
import { ConnectionCard } from "./ConnectionCard";
@@ -127,17 +126,47 @@ export function ConnectionGrid({
const selectedItemIds = useUiStore((s) => s.selectedItemIds);
const toggleItemSelection = useUiStore((s) => s.toggleItemSelection);
const clearSelection = useUiStore((s) => s.clearSelection);
const activeTagIds = useUiStore((s) => s.activeTagIds);
const activeDbTypes = useUiStore((s) => s.activeDbTypes);
const activeEnvironment = useUiStore((s) => s.activeEnvironment);
const currentFolderId =
activeFolderId !== null && folders.some((f) => f.id === activeFolderId)
const currentFolderId = hasSearch
? null
: activeFolderId !== null && folders.some((f) => f.id === activeFolderId)
? activeFolderId
: null;
const hasActiveFilters =
activeTagIds.length > 0 ||
activeDbTypes.length > 0 ||
(activeEnvironment !== null && activeEnvironment !== undefined);
const connectionMatchesFilters = (c: Connection) => {
if (activeTagIds.length > 0 && !c.tag_ids.some((id) => activeTagIds.includes(id))) {
return false;
}
if (activeDbTypes.length > 0 && !activeDbTypes.includes(c.db_type)) {
return false;
}
if (activeEnvironment !== null && activeEnvironment !== undefined && c.environment !== activeEnvironment) {
return false;
}
return true;
};
const visibleFolders = hasSearch
? []
: getChildFolders(folders, currentFolderId);
const directConnections = connections.filter(
(c) => c.folder_id === currentFolderId,
: getChildFolders(folders, currentFolderId).filter((f) => {
if (!hasActiveFilters) return true;
const folderMatchesTags = f.tag_ids.some((id) => activeTagIds.includes(id));
if (folderMatchesTags) return true;
const subIds = new Set(getDescendantFolderIds(folders, f.id));
return connections.some(
(c) => c.folder_id !== null && subIds.has(c.folder_id) && connectionMatchesFilters(c),
);
});
const directConnections = hasSearch
? connections
: connections.filter((c) => c.folder_id === currentFolderId);
const allStoreConnections = useConnectionStore((s) => s.connections);
const allStoreFolders = useConnectionStore((s) => s.folders);
// Check if any direct connections OR any subfolder has connections anywhere below
@@ -171,6 +200,8 @@ export function ConnectionGrid({
folders={folders}
activeFolderId={currentFolderId}
onNavigate={handleBreadcrumbNavigate}
hasSearch={hasSearch}
onClearSearch={() => useUiStore.getState().clearFilters()}
/>
{activeFolder && (
<div className="flex items-center gap-1">
+14 -2
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { GeneralTab } from "./GeneralTab";
import type { ConnectionFormData } from "./connectionFormData";
@@ -19,6 +19,18 @@ const BASE_FORM: ConnectionFormData = {
};
describe("GeneralTab", () => {
it("renders a Name input and passes value to onChange", () => {
const onChange = vi.fn();
render(<GeneralTab form={BASE_FORM} onChange={onChange} />);
const nameInput = screen.getByLabelText("Name");
expect(nameInput).toBeInTheDocument();
expect(nameInput).toHaveValue(BASE_FORM.name);
fireEvent.change(nameInput, { target: { value: "My New Name" } });
expect(onChange).toHaveBeenCalledWith({ name: "My New Name" });
});
it("renders host, port, user, password, and database fields", () => {
render(<GeneralTab form={BASE_FORM} onChange={() => {}} />);
+10
View File
@@ -14,6 +14,16 @@ export function GeneralTab({ form, onChange }: GeneralTabProps) {
return (
<div className="space-y-4">
<div>
<label className="block text-sm text-text mb-1.5">Name</label>
<Input
value={form.name}
onChange={(value) => onChange({ name: value })}
placeholder="My Production Database"
aria-label="Name"
/>
</div>
{!isSqlite && (
<div className="flex gap-3">
<div className="flex-1">
@@ -28,6 +28,23 @@ describe("ChangesQueuePanel", () => {
expect(screen.getByText(/users/i)).toBeInTheDocument();
});
it("toggle button flips the store expanded state", async () => {
const user = userEvent.setup();
useDbViewerStore.getState().addChange({
type: "update",
schema: "public",
table: "users",
primaryKey: { id: 1 },
oldData: { name: "Bob" },
newData: { name: "Alice" },
});
render(<ChangesQueuePanel />);
await user.click(screen.getByText(/1 pending change/i));
expect(useDbViewerStore.getState().changesPanelExpanded).toBe(false);
await user.click(screen.getByText(/1 pending change/i));
expect(useDbViewerStore.getState().changesPanelExpanded).toBe(true);
});
it("cancel button changes status", async () => {
const user = userEvent.setup();
useDbViewerStore.getState().addChange({
@@ -1,4 +1,4 @@
import { useState, useCallback } from "react";
import { useCallback } from "react";
import { X, Check, ChevronUp, ChevronDown } from "lucide-react";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { useUiStore } from "../../stores/uiStore";
@@ -58,7 +58,10 @@ export function ChangesQueuePanel() {
const markChangeCommitted = useDbViewerStore((state) => state.markChangeCommitted);
const markChangeFailed = useDbViewerStore((state) => state.markChangeFailed);
const notify = useNotificationStore((state) => state.notify);
const [expanded, setExpanded] = useState(true);
const expanded = useDbViewerStore((state) => state.changesPanelExpanded);
const toggleChangesPanel = useDbViewerStore(
(state) => state.toggleChangesPanel,
);
const handleCommitAll = useCallback(async () => {
const connectionId = useUiStore.getState().activeConnectionId;
@@ -114,7 +117,7 @@ export function ChangesQueuePanel() {
<div className="border-t border-border bg-surface">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
onClick={() => toggleChangesPanel()}
className="flex w-full items-center justify-between px-4 py-2 text-sm text-text hover:bg-surface-raised/50 cursor-pointer"
>
<div className="flex items-center gap-2">
@@ -1,7 +1,15 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { DbViewerScreen } from "./DbViewerScreen";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import * as commands from "../../lib/commands";
vi.mock("../../hooks/useDbConnection", () => ({
useDbConnection: (_connectionId: string) => ({
connectionError: null,
connect: vi.fn(),
}),
}));
vi.mock("@tanstack/react-virtual", () => ({
useVirtualizer: () => ({
@@ -11,18 +19,388 @@ vi.mock("@tanstack/react-virtual", () => ({
}),
}));
const { registeredActions } = vi.hoisted(() => ({
registeredActions: [] as Array<{ run: () => void }>,
}));
// monaco-editor's global font re-measure — stub so jsdom stays light
vi.mock("monaco-editor", () => ({
editor: { remeasureFonts: vi.fn() },
}));
vi.mock("@monaco-editor/react", async () => {
const { useEffect } = await import("react");
return {
default: ({ value, onChange, onMount }: any) => {
useEffect(() => {
onMount?.({
addAction: (action: any) => registeredActions.push(action),
getValue: () => value,
setValue: (v: string) => onChange?.(v),
focus: () => {},
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div data-testid="monaco-editor">
<textarea
data-testid="monaco-textarea"
value={value}
onChange={(e) => onChange?.(e.target.value)}
/>
</div>
);
},
};
});
const mockQueryResult = {
columns: [
{
name: "id",
data_type: "integer",
is_nullable: false,
is_pk: true,
is_fk: false,
fk_ref: null,
default_value: null,
},
],
rows: [[1]],
total_rows: 1,
page: 1,
page_size: 50,
execution_time_ms: 42,
};
describe("DbViewerScreen", () => {
beforeEach(() => {
useDbViewerStore.getState().reset();
useDbViewerStore.setState({
tabs: [], activeTabId: null, changesQueue: [],
databases: ["mydb"], schemas: ["public"],
tables: [{ name: "users", schema: "public", table_type: "TABLE" }],
currentDatabase: "mydb", currentSchema: "public",
databases: ["mydb"],
schemas: ["public"],
tables: [
{ name: "users", schema: "public", table_type: "TABLE" },
],
currentDatabase: "mydb",
currentSchema: "public",
});
vi.resetAllMocks();
});
it("renders the sidebar", () => {
render(<DbViewerScreen connectionId="c1" onHome={() => {}} onSettings={() => {}} />);
render(
<DbViewerScreen
connectionId="c1"
onHome={() => {}}
onSettings={() => {}}
/>,
);
expect(screen.getByLabelText(/home/i)).toBeInTheDocument();
});
it("renders the New Query button", () => {
render(
<DbViewerScreen
connectionId="c1"
onHome={() => {}}
onSettings={() => {}}
/>,
);
expect(screen.getByRole("button", { name: /new query/i })).toBeInTheDocument();
});
it("opens a query tab when New Query is clicked", () => {
render(
<DbViewerScreen
connectionId="c1"
onHome={() => {}}
onSettings={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /new query/i }));
expect(screen.getByRole("tab", { name: "Query" })).toBeInTheDocument();
});
it("renders the query editor inside a query tab", async () => {
render(
<DbViewerScreen
connectionId="c1"
onHome={() => {}}
onSettings={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /new query/i }));
await waitFor(() => {
expect(screen.getByTestId("monaco-editor")).toBeInTheDocument();
});
expect(screen.getByRole("button", { name: /run query/i })).toBeInTheDocument();
});
it("executes a non-destructive query when Run is clicked", async () => {
const executeQuery = vi
.spyOn(commands, "executeQuery")
.mockResolvedValue(mockQueryResult as any);
render(
<DbViewerScreen
connectionId="c1"
onHome={() => {}}
onSettings={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /new query/i }));
const textarea = await waitFor(() =>
screen.getByTestId("monaco-textarea"),
);
fireEvent.change(textarea, { target: { value: "SELECT 1" } });
fireEvent.click(screen.getByRole("button", { name: /run query/i }));
await waitFor(() =>
expect(executeQuery).toHaveBeenCalledWith("c1", "SELECT 1", 1, 50),
);
// query variant toolbar shows the execution time from the result
await waitFor(() => expect(screen.getByText("42.00ms")).toBeInTheDocument());
expect(screen.getByLabelText(/execution time/i)).toBeInTheDocument();
});
it("shows a destructive-query confirmation dialog and executes on confirm", async () => {
const executeQuery = vi
.spyOn(commands, "executeQuery")
.mockResolvedValue({
columns: [],
rows: [],
total_rows: 0,
page: 1,
page_size: 50,
} as any);
render(
<DbViewerScreen
connectionId="c1"
onHome={() => {}}
onSettings={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /new query/i }));
const textarea = await waitFor(() =>
screen.getByTestId("monaco-textarea"),
);
fireEvent.change(textarea, {
target: { value: "DELETE FROM users" },
});
fireEvent.click(screen.getByRole("button", { name: /run query/i }));
await waitFor(() => {
expect(screen.getByText("Destructive Query")).toBeInTheDocument();
});
expect(executeQuery).not.toHaveBeenCalled();
fireEvent.click(screen.getByText("Execute"));
await waitFor(() =>
expect(executeQuery).toHaveBeenCalledWith(
"c1",
"DELETE FROM users",
1,
50,
),
);
});
it("formats the query SQL when Auto format is clicked", async () => {
render(
<DbViewerScreen
connectionId="c1"
onHome={() => {}}
onSettings={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /new query/i }));
const textarea = await waitFor(() =>
screen.getByTestId("monaco-textarea"),
);
fireEvent.change(textarea, {
target: { value: "select * from users where id = 1" },
});
fireEvent.click(screen.getByRole("button", { name: /auto format/i }));
await waitFor(() => {
expect((textarea as HTMLTextAreaElement).value).toMatch(/\n/);
});
});
it("runs the current query when the Cmd+Enter action fires", async () => {
const executeQuery = vi
.spyOn(commands, "executeQuery")
.mockResolvedValue(mockQueryResult as any);
render(
<DbViewerScreen
connectionId="c1"
onHome={() => {}}
onSettings={() => {}}
/>,
);
registeredActions.length = 0;
fireEvent.click(screen.getByRole("button", { name: /new query/i }));
const textarea = await waitFor(() =>
screen.getByTestId("monaco-textarea"),
);
fireEvent.change(textarea, {
target: { value: "SELECT 42" },
});
expect(registeredActions).toHaveLength(1);
registeredActions[0].run();
await waitFor(() =>
expect(executeQuery).toHaveBeenCalledWith("c1", "SELECT 42", 1, 50),
);
});
it("shows the pulse while a query is running and hides it after", async () => {
let resolveRun!: (v: unknown) => void;
const pending = new Promise<unknown>((r) => {
resolveRun = r;
});
vi.spyOn(commands, "executeQuery").mockReturnValue(pending as any);
render(
<DbViewerScreen
connectionId="c1"
onHome={() => {}}
onSettings={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /new query/i }));
const textarea = await waitFor(() =>
screen.getByTestId("monaco-textarea"),
);
fireEvent.change(textarea, { target: { value: "SELECT 1" } });
fireEvent.click(screen.getByRole("button", { name: /run query/i }));
await waitFor(() =>
expect(screen.getByTestId("query-run-pulse")).toBeInTheDocument(),
);
resolveRun(mockQueryResult);
await waitFor(() =>
expect(screen.queryByTestId("query-run-pulse")).toBeNull(),
);
});
it("collapses and re-expands the query results via the caret", async () => {
const executeQuery = vi
.spyOn(commands, "executeQuery")
.mockResolvedValue(mockQueryResult as any);
render(
<DbViewerScreen
connectionId="c1"
onHome={() => {}}
onSettings={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /new query/i }));
const textarea = await waitFor(() =>
screen.getByTestId("monaco-textarea"),
);
fireEvent.change(textarea, { target: { value: "SELECT 1" } });
fireEvent.click(screen.getByRole("button", { name: /run query/i }));
await waitFor(() => expect(screen.getByText("42.00ms")).toBeInTheDocument());
expect(screen.getByTestId("query-results")).toBeInTheDocument();
fireEvent.click(screen.getByLabelText(/hide results/i));
expect(screen.queryByTestId("query-results")).toBeNull();
expect(screen.queryByText("42.00ms")).toBeNull();
fireEvent.click(screen.getByLabelText(/show results/i));
expect(screen.getByTestId("query-results")).toBeInTheDocument();
expect(executeQuery).toHaveBeenCalledTimes(1);
});
it("resizes the results panel with a drag handle, clamped to min/max", async () => {
vi.spyOn(commands, "executeQuery").mockResolvedValue(
mockQueryResult as any,
);
render(
<DbViewerScreen
connectionId="c1"
onHome={() => {}}
onSettings={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /new query/i }));
const textarea = await waitFor(() =>
screen.getByTestId("monaco-textarea"),
);
fireEvent.change(textarea, { target: { value: "SELECT 1" } });
fireEvent.click(screen.getByRole("button", { name: /run query/i }));
await waitFor(() => expect(screen.getByText("42.00ms")).toBeInTheDocument());
const results = screen.getByTestId("query-results");
const initial = parseFloat(results.style.height);
const handle = screen.getByTestId("query-results-resize");
// Drag up: results grow
fireEvent.mouseDown(handle, { clientY: 200 });
fireEvent.mouseMove(document, { clientY: 100 });
fireEvent.mouseUp(document);
await waitFor(() =>
expect(parseFloat(results.style.height)).toBeGreaterThan(initial),
);
// Drag far down: clamps to the 120px minimum
fireEvent.mouseDown(handle, { clientY: 200 });
fireEvent.mouseMove(document, { clientY: 5000 });
fireEvent.mouseUp(document);
await waitFor(() => expect(parseFloat(results.style.height)).toBe(120));
});
it("re-fetches the active table and shows the refresh indicator when refresh is clicked", async () => {
let resolveFetch!: (v: unknown) => void;
const pendingFetch = new Promise<unknown>((r) => {
resolveFetch = r;
});
const getTableData = vi
.spyOn(commands, "getTableData")
.mockReturnValue(pendingFetch 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",
});
render(
<DbViewerScreen
connectionId="c1"
onHome={() => {}}
onSettings={() => {}}
/>,
);
// Tab already has data and is not loading → no fetch on mount
expect(getTableData).not.toHaveBeenCalled();
fireEvent.click(screen.getByLabelText(/refresh table/i));
// Refetch triggered for the active tab
await waitFor(() => expect(getTableData).toHaveBeenCalledTimes(1));
// Indicator visible while the fetch is in flight
await waitFor(() =>
expect(screen.getByTestId("refresh-pulse")).toBeInTheDocument(),
);
act(() => {
resolveFetch({ ...mockQueryResult });
});
await waitFor(() =>
expect(
screen.queryByTestId("refresh-pulse"),
).not.toBeInTheDocument(),
);
});
});
+385 -10
View File
@@ -1,7 +1,17 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState, Suspense, lazy } from "react";
import { ChevronDown, ChevronUp } from "lucide-react";
import { format as formatSql } from "sql-formatter";
import { TooltipProvider } from "../ui/Tooltip";
import { DbViewerSidebar } from "./DbViewerSidebar";
import { DbViewerToolbar } from "./DbViewerToolbar";
import { isDestructiveQuery } from "../../lib/utils";
import { executeQuery } from "../../lib/commands";
const QueryEditor = lazy(() => import("../editor/QueryEditor").then((m) => ({ default: m.QueryEditor })));
import { QueryToolbar } from "../editor/QueryToolbar";
const DestructiveQueryDialog = lazy(() =>
import("../editor/DestructiveQueryDialog").then((m) => ({ default: m.DestructiveQueryDialog })),
);
import { TableTree } from "./TableTree";
import { ObjectExplorerPage } from "./ObjectExplorerPage";
import { TabBar } from "./TabBar";
@@ -39,6 +49,7 @@ export function DbViewerScreen({
const [searchQuery, setSearchQuery] = useState("");
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
const [editModalOpen, setEditModalOpen] = useState(false);
const [destructiveQuery, setDestructiveQuery] = useState<string | null>(null);
const connections = useConnectionStore((s) => s.connections);
const currentConnection =
connections.find((c) => c.id === connectionId) ?? null;
@@ -72,6 +83,7 @@ export function DbViewerScreen({
const setTabData = useDbViewerStore((s) => s.setTabData);
const setTabError = useDbViewerStore((s) => s.setTabError);
const setTabLoading = useDbViewerStore((s) => s.setTabLoading);
const databases = useDbViewerStore((s) => s.databases);
const currentDatabase = useDbViewerStore((s) => s.currentDatabase);
const setCurrentDatabase = useDbViewerStore((s) => s.setCurrentDatabase);
@@ -105,6 +117,57 @@ export function DbViewerScreen({
[connectionId, setTabData, setTabError],
);
async function executeQueryForTab(tabId: string, sql: string) {
const tab = useDbViewerStore.getState().tabs.find((t) => t.id === tabId);
if (!tab) return;
setTabLoading(tabId, true);
try {
const result = await executeQuery(connectionId, sql, tab.page, tab.pageSize);
setTabData(tabId, result);
} catch (e) {
setTabError(tabId, e instanceof Error ? e.message : String(e));
}
}
// 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(() => {
const state = useDbViewerStore.getState();
const tab = state.tabs.find((t) => t.id === state.activeTabId);
if (!tab || tab.tabType !== "query") return;
const sql = tab.query?.trim() ?? "";
if (!sql) return;
if (isDestructiveQuery(sql)) {
setDestructiveQuery(sql);
} else {
executeQueryForTab(tab.id, sql);
}
}, []);
// Auto-format the active query tab's SQL
const handleFormatQuery = useCallback(() => {
const state = useDbViewerStore.getState();
const tab = state.tabs.find((t) => t.id === state.activeTabId);
if (!tab || tab.tabType !== "query") return;
const dbType = currentConnection?.db_type ?? "postgresql";
const language =
dbType === "mysql"
? "mysql"
: dbType === "sqlite"
? "sqlite"
: "postgresql";
try {
const formatted = formatSql(tab.query ?? "", { language });
useDbViewerStore.setState((s) => ({
tabs: s.tabs.map((t) =>
t.id === tab.id ? { ...t, query: formatted } : t,
),
}));
} catch {
// leave the query untouched if formatting fails
}
}, [currentConnection?.db_type]);
// Cmd+W / Ctrl+W: close current tab, or navigate home if no tabs (configurable in Settings → Shortcuts)
useShortcut("close_tab", () => {
const state = useDbViewerStore.getState();
@@ -116,6 +179,7 @@ export function DbViewerScreen({
});
useEffect(() => {
if (!activeTab) return;
if (activeTab.tabType !== "table") return;
if (!activeTab.loading) return;
if (activeTab.error) return;
fetchData(activeTab);
@@ -124,6 +188,7 @@ export function DbViewerScreen({
// Smart default sort: apply once when data first loads for a tab
useEffect(() => {
if (!activeTab) return;
if (activeTab.tabType !== "table") return;
if (activeTab.loading) return;
if (!activeTab.data) return;
if (activeTab.smartSortApplied) return;
@@ -370,10 +435,17 @@ export function DbViewerScreen({
}
}, [filterRules, activeTab, clearColumnFilter]);
// Refresh: clear data so auto-fetch effect re-fetches
// Refresh: clear data so auto-fetch effect re-fetches; for query tabs, re-run the stored query
const handleRefresh = useCallback(() => {
const tabId = useDbViewerStore.getState().activeTabId;
if (!tabId) return;
const tab = useDbViewerStore.getState().tabs.find((t) => t.id === tabId);
if (!tab) return;
if (tab.tabType === "query") {
const sql = tab.query?.trim() ?? "";
if (sql) executeQueryForTab(tabId, sql);
return;
}
useDbViewerStore.setState((s) => ({
tabs: s.tabs.map((t) =>
t.id === tabId ? { ...t, loading: true, error: null } : t,
@@ -416,6 +488,47 @@ export function DbViewerScreen({
[tablePanelWidth],
);
// Query results panel: collapsible + resizable (min 120px, max 80% of column)
const queryColumnRef = useRef<HTMLDivElement>(null);
const resultsResizeRef = useRef<{ startY: number; startH: number } | null>(
null,
);
const [resultsHeight, setResultsHeight] = useState(() =>
Math.round(
(typeof window !== "undefined" ? window.innerHeight : 800) * 0.4,
),
);
const [resultsCollapsed, setResultsCollapsed] = useState(false);
const onResultsResizeStart = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
resultsResizeRef.current = {
startY: e.clientY,
startH: resultsHeight,
};
const columnH =
queryColumnRef.current?.clientHeight ||
(typeof window !== "undefined" ? window.innerHeight : 800);
const maxH = Math.max(120, Math.round(columnH * 0.8));
const onMove = (ev: MouseEvent) => {
if (!resultsResizeRef.current) return;
const h =
resultsResizeRef.current.startH +
(resultsResizeRef.current.startY - ev.clientY);
setResultsHeight(Math.max(120, Math.min(maxH, h)));
};
const onUp = () => {
resultsResizeRef.current = null;
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
},
[resultsHeight],
);
const handleNavigate = useCallback(
(view: string) => {
if (view === "home") onHome();
@@ -479,6 +592,250 @@ export function DbViewerScreen({
/>
<div className="flex-1 w-0 flex flex-col min-w-0 overflow-hidden">
<TabBar />
{activeTab?.tabType === "query" ? (
<Suspense
fallback={
<div className="p-4 text-text-muted">
Loading editor...
</div>
}
>
<div ref={queryColumnRef} className="flex flex-col flex-1 min-h-0 overflow-hidden">
<QueryToolbar
onRun={handleRunQuery}
onFormat={handleFormatQuery}
dbType={currentConnection?.db_type}
/>
<div className="flex-1 min-h-0 overflow-hidden">
<QueryEditor
value={activeTab.query ?? ""}
onChange={(value) =>
useDbViewerStore.setState(
(s) => ({
tabs: s.tabs.map(
(t) =>
t.id ===
activeTab.id
? {
...t,
query: value,
}
: t,
),
}),
)
}
onRun={handleRunQuery}
/>
</div>
{!resultsCollapsed ? (
<>
<div className="relative shrink-0">
<div
data-testid="query-results-resize"
aria-label="Resize results"
onMouseDown={
onResultsResizeStart
}
onDoubleClick={() =>
setResultsHeight(
Math.round(
(typeof window !==
"undefined"
? window
.innerHeight
: 800) *
0.4,
),
)
}
className="h-1 cursor-row-resize bg-border/20 hover:bg-accent/30 active:bg-accent/50"
/>
{/* caret pill, centered on the drag strip */}
<button
type="button"
onClick={() =>
setResultsCollapsed(
true,
)
}
aria-label="Hide results"
onMouseDown={(e) =>
e.stopPropagation()
}
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 flex items-center justify-center rounded-full border border-border bg-surface px-2 py-0.5 text-text-muted hover:text-text hover:bg-surface-raised shadow-sm transition-colors cursor-pointer"
>
<ChevronDown size={12} />
</button>
</div>
<div
data-testid="query-results"
style={{
height: resultsHeight,
}}
className="flex flex-col min-h-0 shrink-0"
>
{activeTab?.data && (
<TableControls
connectionId={connectionId}
schema={activeSchema}
table={activeTable}
columns={columns}
rows={rawRows}
hiddenColumns={
hiddenColumns
}
onToggleColumn={(
col,
) =>
toggleHiddenColumn(
activeTab!.id,
col,
)
}
onRefresh={
handleRefresh
}
filterRules={
filterRules
}
onFilterChange={(
rules,
) =>
setFilterRules(
activeTab!.id,
rules,
)
}
sortRules={
sortRules
}
onSortChange={(
rules,
) =>
setSortRules(
activeTab!.id,
rules,
)
}
defaultRefreshRate={
settings?.table_refresh_rate ??
0
}
selectedCount={
selectedRows.size
}
selectedRows={processedRows.filter(
(_, i) =>
selectedRows.has(
i,
),
)}
onClearSelection={() =>
setSelectedRows(
new Set(),
)
}
variant="query"
/>
)}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<VirtualDataGrid
connectionId={connectionId}
schema={activeSchema}
rows={processedRows}
columns={columns}
hiddenColumns={hiddenColumns}
selectedRows={selectedRows}
onToggleRow={(rowIndex) => {
setSelectedRows(
(prev) => {
const next =
new Set(
prev,
);
if (
next.has(
rowIndex,
)
)
next.delete(
rowIndex,
);
else
next.add(
rowIndex,
);
return next;
},
);
}}
onToggleAll={() => {
setSelectedRows(
(prev) => {
if (
prev.size ===
processedRows.length &&
processedRows.length >
0
) {
return new Set();
}
return new Set(
processedRows.map(
(_, i) =>
i,
),
);
},
);
}}
/>
</div>
</div>
</>
) : (
<>
{/* collapsed caret pill, pinned to the bottom of the editor */}
<div className="flex shrink-0 justify-center py-1">
<button
type="button"
onClick={() =>
setResultsCollapsed(
false,
)
}
aria-label="Show results"
className="flex items-center justify-center rounded-full border border-border bg-surface px-2 py-0.5 text-text-muted hover:text-text hover:bg-surface-raised shadow-sm transition-colors cursor-pointer"
>
<ChevronUp size={12} />
</button>
</div>
</>
)}
<DestructiveQueryDialog
open={destructiveQuery !== null}
query={destructiveQuery ?? ""}
onConfirm={() => {
if (
destructiveQuery &&
activeTab
) {
executeQueryForTab(
activeTab.id,
destructiveQuery,
);
}
setDestructiveQuery(null);
}}
onCancel={() =>
setDestructiveQuery(null)
}
/>
</div>
</Suspense>
) : (
<>
{activeTab?.data && (
<TableControls
connectionId={connectionId}
@@ -488,23 +845,34 @@ export function DbViewerScreen({
rows={rawRows}
hiddenColumns={hiddenColumns}
onToggleColumn={(col) =>
toggleHiddenColumn(activeTab!.id, col)
toggleHiddenColumn(
activeTab!.id,
col,
)
}
onRefresh={handleRefresh}
filterRules={filterRules}
onFilterChange={(rules) =>
setFilterRules(activeTab!.id, rules)
setFilterRules(
activeTab!.id,
rules,
)
}
sortRules={sortRules}
onSortChange={(rules) =>
setSortRules(activeTab!.id, rules)
setSortRules(
activeTab!.id,
rules,
)
}
defaultRefreshRate={
settings?.table_refresh_rate ?? 0
settings?.table_refresh_rate ??
0
}
selectedCount={selectedRows.size}
selectedRows={processedRows.filter(
(_, i) => selectedRows.has(i),
(_, i) =>
selectedRows.has(i),
)}
onClearSelection={() =>
setSelectedRows(new Set())
@@ -521,9 +889,13 @@ export function DbViewerScreen({
selectedRows={selectedRows}
onToggleRow={(rowIndex) => {
setSelectedRows((prev) => {
const next = new Set(prev);
const next = new Set(
prev,
);
if (next.has(rowIndex))
next.delete(rowIndex);
next.delete(
rowIndex,
);
else next.add(rowIndex);
return next;
});
@@ -533,7 +905,8 @@ export function DbViewerScreen({
if (
prev.size ===
processedRows.length &&
processedRows.length > 0
processedRows.length >
0
) {
return new Set();
}
@@ -546,6 +919,8 @@ export function DbViewerScreen({
}}
/>
</div>
</>
)}
</div>
</div>
) : currentView === "functions" ? (
+13 -13
View File
@@ -31,43 +31,43 @@ export function DbViewerSidebar({
onNavigate,
}: DbViewerSidebarProps) {
const topItems: NavItem[] = [
{ id: "db-viewer", label: "Explorer", icon: <Database size={20} /> },
{ id: "db-viewer", label: "Explorer", icon: <Database size={16} /> },
{
id: "schema-visualizer",
label: "Schema Visualizer",
icon: <Grid2x2 size={20} />,
icon: <Grid2x2 size={16} />,
},
{
id: "functions",
label: "Functions",
icon: <FunctionSquare size={20} />,
icon: <FunctionSquare size={16} />,
},
{ id: "triggers", label: "Triggers", icon: <GitBranch size={20} /> },
{ id: "triggers", label: "Triggers", icon: <GitBranch size={16} /> },
{
id: "sequences",
label: "Sequences",
icon: <ListOrdered size={20} />,
icon: <ListOrdered size={16} />,
},
{ id: "enums", label: "Enums", icon: <Tag size={20} /> },
{ id: "extensions", label: "Extensions", icon: <Puzzle size={20} /> },
{ id: "backup", label: "Backup", icon: <Download size={20} /> },
{ id: "restore", label: "Restore", icon: <Upload size={20} /> },
{ id: "enums", label: "Enums", icon: <Tag size={16} /> },
{ id: "extensions", label: "Extensions", icon: <Puzzle size={16} /> },
{ id: "backup", label: "Backup", icon: <Download size={16} /> },
{ id: "restore", label: "Restore", icon: <Upload size={16} /> },
{
id: "sync",
label: "DB Sync",
icon: <ArrowLeftRight size={20} />,
icon: <ArrowLeftRight size={16} />,
},
];
const bottomItems: NavItem[] = [
{ id: "home", label: "Home", icon: <Home size={20} /> },
{ id: "settings", label: "Settings", icon: <Settings size={20} /> },
{ id: "home", label: "Home", icon: <Home size={16} /> },
{ id: "settings", label: "Settings", icon: <Settings size={16} /> },
];
function renderItem(item: NavItem) {
const isActive = currentView === item.id;
const baseClass =
"w-10 h-10 flex items-center justify-center rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-accent/50";
"w-8 h-8 flex items-center justify-center rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-accent/50";
const activeClass = "text-accent";
const inactiveClass =
"text-text-muted hover:text-text hover:bg-surface-raised";
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { render, screen, fireEvent } from "@testing-library/react";
import { DbViewerToolbar } from "./DbViewerToolbar";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { TooltipProvider } from "../ui/Tooltip";
@@ -42,6 +42,38 @@ describe("DbViewerToolbar", () => {
expect(screen.getByText("mydb")).toBeInTheDocument();
});
it("omits bottom padding when nothing is rendered below the title row", () => {
const { container } = render(
<TooltipProvider>
<DbViewerToolbar {...defaultProps} />
</TooltipProvider>,
);
expect(container.firstElementChild!.className).not.toContain("pb-3");
});
it("keeps bottom padding when selectors are rendered below", () => {
const { container } = render(
<TooltipProvider>
<DbViewerToolbar
{...defaultProps}
databases={["mydb", "otherdb"]}
currentDatabase="mydb"
/>
</TooltipProvider>,
);
expect(container.firstElementChild!.className).toContain("pb-3");
});
it("keeps bottom padding while the search input is open", () => {
const { container } = render(
<TooltipProvider>
<DbViewerToolbar {...defaultProps} />
</TooltipProvider>,
);
fireEvent.click(screen.getByLabelText(/search tables/i));
expect(container.firstElementChild!.className).toContain("pb-3");
});
it("renders refresh and create table buttons", () => {
render(
<TooltipProvider>
+7 -2
View File
@@ -94,10 +94,15 @@ export function DbViewerToolbar({
}
}, [connectionId, refreshing, populate]);
const hasBelow =
searchOpen || databases.length > 1 || schemas.length > 1;
return (
<div className="p-3 border-b border-border space-y-2">
<div
className={`px-3 pt-3 border-b border-border space-y-2 ${hasBelow ? "pb-3" : ""}`}
>
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-text">Tables</span>
<span className="text-sm font-normal text-text-muted">Tables</span>
<div className="flex items-center gap-1">
{onEdit && (
<Tooltip content="Edit Connection" side="bottom">
@@ -1,8 +1,28 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import type { Mock } from "vitest";
import { SchemaVisualizerPage } from "./SchemaVisualizerPage";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { TooltipProvider } from "../ui/Tooltip";
import { toPng } from "html-to-image";
import { save } from "@tauri-apps/plugin-dialog";
import { writeFile } from "@tauri-apps/plugin-fs";
// Mock html-to-image so exports don't hit real DOM capture in jsdom
vi.mock("html-to-image", () => ({
toPng: vi.fn().mockResolvedValue("data:image/png;base64,AAAA"),
toJpeg: vi.fn().mockResolvedValue("data:image/jpeg;base64,AAAA"),
toSvg: vi.fn().mockResolvedValue("data:image/svg+xml;base64,AAAA"),
}));
// Mock the Tauri save dialog and fs write
vi.mock("@tauri-apps/plugin-dialog", () => ({
save: vi.fn().mockResolvedValue("/tmp/export.png"),
}));
vi.mock("@tauri-apps/plugin-fs", () => ({
writeFile: vi.fn().mockResolvedValue(undefined),
}));
// Mock the Tauri invoke call
vi.mock("@tauri-apps/api/core", () => ({
@@ -19,7 +39,9 @@ vi.mock("./SchemaVisualizerNode", () => ({
describe("SchemaVisualizerPage", () => {
beforeEach(() => {
vi.clearAllMocks();
useDbViewerStore.getState().reset();
useNotificationStore.setState({ notifications: [] });
useDbViewerStore.setState({
schemas: ["public", "auth"],
currentSchema: "public",
@@ -77,4 +99,348 @@ describe("SchemaVisualizerPage", () => {
const errorMsg = await screen.findByText(/failed to load schema/i);
expect(errorMsg).toBeInTheDocument();
});
it("renders the Export menu with a scope selector and format options", () => {
render(
<TooltipProvider>
<SchemaVisualizerPage
connectionId="conn-1"
onSchemaChange={() => {}}
/>
</TooltipProvider>,
);
fireEvent.click(screen.getByText(/export/i));
expect(screen.getByLabelText("Export scope")).toBeInTheDocument();
expect(screen.getByText("PNG")).toBeInTheDocument();
expect(screen.getByText("JPEG")).toBeInTheDocument();
expect(screen.getByText("SVG")).toBeInTheDocument();
});
it("exports the viewport as PNG when Viewport scope is selected", async () => {
const { invoke } = await import("@tauri-apps/api/core");
(invoke as any).mockReset();
(invoke as any).mockResolvedValue({
tables: [
{
name: "users",
schema: "public",
table_type: "TABLE",
columns: [
{
name: "id",
data_type: "integer",
is_pk: true,
is_fk: false,
is_unique: true,
is_nullable: false,
fk_ref: null,
},
],
},
],
relationships: [],
});
render(
<TooltipProvider>
<SchemaVisualizerPage
connectionId="conn-1"
onSchemaChange={() => {}}
/>
</TooltipProvider>,
);
await screen.findByText("1 table");
fireEvent.click(screen.getByText(/export/i));
// pick Viewport scope, then PNG
fireEvent.click(screen.getByLabelText("Export scope"));
fireEvent.click(screen.getByText("Viewport"));
fireEvent.click(screen.getByText("PNG"));
expect(toPng).toHaveBeenCalledTimes(1);
const [, options] = (toPng as Mock).mock.calls[0];
// Viewport export keeps the current view — no transform override
expect(options.style).toBeUndefined();
expect(options.width).toBeGreaterThan(0);
});
it("exports the entire schema as PNG with a computed transform", async () => {
const { invoke } = await import("@tauri-apps/api/core");
(invoke as any).mockReset();
(invoke as any).mockResolvedValue({
tables: [
{
name: "users",
schema: "public",
table_type: "TABLE",
columns: [
{
name: "id",
data_type: "integer",
is_pk: true,
is_fk: false,
is_unique: true,
is_nullable: false,
fk_ref: null,
},
],
},
],
relationships: [],
});
render(
<TooltipProvider>
<SchemaVisualizerPage
connectionId="conn-1"
onSchemaChange={() => {}}
/>
</TooltipProvider>,
);
await screen.findByText("1 table");
fireEvent.click(screen.getByText(/export/i));
fireEvent.click(screen.getByText("PNG")); // scope defaults to Entire Schema
expect(toPng).toHaveBeenCalledTimes(1);
const [, options] = (toPng as Mock).mock.calls[0];
expect(options.style.transform).toContain("scale(");
});
it("shows an export error when the schema has no nodes", async () => {
const { invoke } = await import("@tauri-apps/api/core");
(invoke as any).mockReset();
(invoke as any).mockResolvedValue({ tables: [], relationships: [] });
render(
<TooltipProvider>
<SchemaVisualizerPage
connectionId="conn-1"
onSchemaChange={() => {}}
/>
</TooltipProvider>,
);
// default mock resolves an empty graph
await screen.findByText(/no tables found/i);
fireEvent.click(screen.getByText(/export/i));
fireEvent.click(screen.getByText("PNG"));
expect(await screen.findByText(/export failed/i)).toBeInTheDocument();
});
it("saves through the dialog and notifies with the file path", async () => {
(save as Mock).mockResolvedValue("/Users/me/Pictures/public-export.png");
const { invoke } = await import("@tauri-apps/api/core");
(invoke as any).mockReset();
(invoke as any).mockResolvedValue({
tables: [
{
name: "users",
schema: "public",
table_type: "TABLE",
columns: [
{
name: "id",
data_type: "integer",
is_pk: true,
is_fk: false,
is_unique: true,
is_nullable: false,
fk_ref: null,
},
],
},
],
relationships: [],
});
render(
<TooltipProvider>
<SchemaVisualizerPage
connectionId="conn-1"
onSchemaChange={() => {}}
/>
</TooltipProvider>,
);
await screen.findByText("1 table");
fireEvent.click(screen.getByText(/export/i));
fireEvent.click(screen.getByText("PNG"));
await waitFor(() => expect(writeFile).toHaveBeenCalledTimes(1));
expect(writeFile).toHaveBeenCalledWith(
"/Users/me/Pictures/public-export.png",
expect.any(Uint8Array),
);
const notification = useNotificationStore
.getState()
.notifications.find((n) => n.message.includes("exported to"));
expect(notification).toBeTruthy();
expect(notification!.message).toContain(
"/Users/me/Pictures/public-export.png",
);
});
it("defaults the filename to the db name plus a locale timestamp", async () => {
(save as Mock).mockResolvedValue(null);
const { invoke } = await import("@tauri-apps/api/core");
(invoke as any).mockReset();
(invoke as any).mockResolvedValue({
tables: [
{
name: "users",
schema: "public",
table_type: "TABLE",
columns: [
{
name: "id",
data_type: "integer",
is_pk: true,
is_fk: false,
is_unique: true,
is_nullable: false,
fk_ref: null,
},
],
},
],
relationships: [],
});
render(
<TooltipProvider>
<SchemaVisualizerPage
connectionId="conn-1"
onSchemaChange={() => {}}
/>
</TooltipProvider>,
);
await screen.findByText("1 table");
fireEvent.click(screen.getByText(/export/i));
fireEvent.click(screen.getByText("PNG"));
await waitFor(() => expect(save).toHaveBeenCalled());
const { defaultPath } = (save as Mock).mock.calls[0][0];
// schema is "public" here; timestamp is locale-formatted then sanitized
expect(defaultPath).toMatch(/^public-.*\.png$/);
});
it("exports a transparent PNG when a transparent background is selected", async () => {
const { invoke } = await import("@tauri-apps/api/core");
(invoke as any).mockReset();
(invoke as any).mockResolvedValue({
tables: [
{
name: "users",
schema: "public",
table_type: "TABLE",
columns: [
{
name: "id",
data_type: "integer",
is_pk: true,
is_fk: false,
is_unique: true,
is_nullable: false,
fk_ref: null,
},
],
},
],
relationships: [],
});
render(
<TooltipProvider>
<SchemaVisualizerPage
connectionId="conn-1"
onSchemaChange={() => {}}
/>
</TooltipProvider>,
);
await screen.findByText("1 table");
fireEvent.click(screen.getByText(/export/i));
fireEvent.click(screen.getByLabelText("Export background"));
fireEvent.click(screen.getByText("Transparent"));
fireEvent.click(screen.getByText("PNG"));
await waitFor(() => expect(toPng).toHaveBeenCalled());
const [, options] = (toPng as Mock).mock.calls[0];
expect(options.backgroundColor).toBeUndefined();
});
it("hides the JPEG option when a transparent background is selected", async () => {
render(
<TooltipProvider>
<SchemaVisualizerPage
connectionId="conn-1"
onSchemaChange={() => {}}
/>
</TooltipProvider>,
);
fireEvent.click(screen.getByText(/export/i));
// JPEG is available with an opaque background by default
expect(screen.getByText("JPEG")).toBeInTheDocument();
fireEvent.click(screen.getByLabelText("Export background"));
fireEvent.click(screen.getByText("Transparent"));
expect(screen.queryByText("JPEG")).not.toBeInTheDocument();
expect(screen.getByText("PNG")).toBeInTheDocument();
expect(screen.getByText("SVG")).toBeInTheDocument();
});
it("does not write the file when the save dialog is cancelled", async () => {
(save as Mock).mockResolvedValue(null);
const { invoke } = await import("@tauri-apps/api/core");
(invoke as any).mockReset();
(invoke as any).mockResolvedValue({
tables: [
{
name: "users",
schema: "public",
table_type: "TABLE",
columns: [
{
name: "id",
data_type: "integer",
is_pk: true,
is_fk: false,
is_unique: true,
is_nullable: false,
fk_ref: null,
},
],
},
],
relationships: [],
});
render(
<TooltipProvider>
<SchemaVisualizerPage
connectionId="conn-1"
onSchemaChange={() => {}}
/>
</TooltipProvider>,
);
await screen.findByText("1 table");
fireEvent.click(screen.getByText(/export/i));
fireEvent.click(screen.getByText("PNG"));
await waitFor(() => expect(save).toHaveBeenCalled());
expect(writeFile).not.toHaveBeenCalled();
expect(
useNotificationStore
.getState()
.notifications.some((n) => n.message.includes("exported to")),
).toBe(false);
});
});
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ReactFlow,
MiniMap,
@@ -6,18 +6,24 @@ import {
Background,
useNodesState,
useEdgesState,
getNodesBounds,
getViewportForBounds,
type Node,
type Edge,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import dagre from "dagre";
import { RotateCcw, ChevronUp, ChevronDown } from "lucide-react";
import { RotateCcw, ChevronUp, ChevronDown, Download, Loader2 } from "lucide-react";
import { toPng, toJpeg, toSvg } from "html-to-image";
import { save } from "@tauri-apps/plugin-dialog";
import { writeFile } from "@tauri-apps/plugin-fs";
import { CrowsFootEdge } from "./CrowsFootEdge";
import { SchemaVisualizerNode } from "./SchemaVisualizerNode";
import { LEGEND_ITEMS } from "./legendHelpers";
import { SelectDropdown } from "../ui/SelectDropdown";
import { getSchemaGraph } from "../../lib/commands";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { useNotificationStore } from "../../stores/notificationStore";
import type { SchemaGraph, TableNode as TableNodeType } from "../../lib/types";
const nodeTypes = { tableNode: SchemaVisualizerNode };
@@ -27,6 +33,20 @@ const CARD_WIDTH = 240;
const ROW_HEIGHT = 28;
const HEADER_HEIGHT = 32;
// Export size for "Entire Schema" renders
const EXPORT_WIDTH = 1600;
const EXPORT_HEIGHT = 1000;
/**
* Decode an html-to-image data URL (base64 or URL-encoded) into bytes so it
* can be written to disk via the Tauri fs plugin.
*/
function dataUrlToBytes(dataUrl: string): Uint8Array {
const [meta, payload] = dataUrl.split(",");
const raw = /;base64/i.test(meta) ? atob(payload) : decodeURIComponent(payload);
return Uint8Array.from(raw, (c) => c.charCodeAt(0));
}
function getNodeHeight(colCount: number): number {
return HEADER_HEIGHT + colCount * ROW_HEIGHT + 4;
}
@@ -59,6 +79,8 @@ function layoutGraph(
position: { x: 0, y: 0 },
data: { table, isExternal: false },
style: { width: CARD_WIDTH },
width: CARD_WIDTH,
height,
});
}
@@ -148,6 +170,134 @@ export function SchemaVisualizerPage({
const [legendOpen, setLegendOpen] = useState(true);
const [highlightedEdge, setHighlightedEdge] = useState<string | null>(null);
// Export state
const containerRef = useRef<HTMLDivElement>(null);
const exportMenuRef = useRef<HTMLDivElement>(null);
const exportButtonRef = useRef<HTMLButtonElement>(null);
const [exportOpen, setExportOpen] = useState(false);
const [exportScope, setExportScope] = useState<"schema" | "viewport">(
"schema",
);
const [exporting, setExporting] = useState(false);
const [exportError, setExportError] = useState<string | null>(null);
const [exportBackground, setExportBackground] = useState<
"opaque" | "transparent"
>("opaque");
const transparent = exportBackground === "transparent";
const notify = useNotificationStore((s) => s.notify);
// Close the export menu on outside click (ignoring the trigger button)
useEffect(() => {
if (!exportOpen) return;
const close = (e: MouseEvent) => {
const target = e.target as Element | null;
if (exportButtonRef.current?.contains(target)) return;
if (exportMenuRef.current?.contains(target)) return;
setExportOpen(false);
};
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, [exportOpen]);
const handleExport = useCallback(
async (scope: "schema" | "viewport", format: "png" | "jpeg" | "svg") => {
const element = document.querySelector<HTMLElement>(
".react-flow__viewport",
);
if (!element) return;
setExporting(true);
setExportError(null);
try {
let width: number;
let height: number;
let style: Partial<CSSStyleDeclaration> | undefined;
if (scope === "viewport") {
const container = containerRef.current;
width = container?.clientWidth || 1024;
height = container?.clientHeight || 768;
} else {
width = EXPORT_WIDTH;
height = EXPORT_HEIGHT;
const bounds = getNodesBounds(nodes);
if (bounds.width === 0 && bounds.height === 0) {
throw new Error("Nothing to export");
}
const viewport = getViewportForBounds(
bounds,
width,
height,
0.5,
2,
0.05,
);
style = {
width: `${width}px`,
height: `${height}px`,
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})`,
};
}
const options = {
// JPEG has no alpha channel; transparency only applies to PNG/SVG
...(transparent && format !== "jpeg"
? {}
: { backgroundColor: "#0a0a0b" }),
width,
height,
style,
pixelRatio: 2,
};
const dataUrl =
format === "png"
? await toPng(element, options)
: format === "jpeg"
? await toJpeg(element, { ...options, quality: 0.95 })
: await toSvg(element, options);
// Filename: <db name>-<locale timestamp>.<ext>
const dbName = currentDatabase ?? currentSchema ?? "schema";
const timestamp = new Date()
.toLocaleString()
.replace(/[\\/:*?"<>|]/g, "-")
.replace(/\s+/g, "-");
const ext = format === "jpeg" ? "jpg" : format;
const filename = `${dbName}-${timestamp}.${ext}`;
const bytes = dataUrlToBytes(dataUrl);
let savedPath: string | null = null;
try {
const path = await save({
defaultPath: filename,
filters: [
{ name: format.toUpperCase(), extensions: [ext] },
],
});
if (path) {
await writeFile(path, bytes);
savedPath = path;
}
} catch {
// Not running in Tauri (e.g. plain browser dev): fall back to the
// webview's default download handler.
const a = document.createElement("a");
a.href = dataUrl;
a.download = filename;
a.click();
}
if (savedPath) {
notify(`Schema exported to ${savedPath}`, "success");
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
setExportError(`Export failed: ${msg}`);
} finally {
setExporting(false);
setExportOpen(false);
}
},
[nodes, currentSchema, currentDatabase, exportBackground, notify],
);
const fetchGraph = useCallback(async () => {
if (!currentSchema) return;
setLoading(true);
@@ -240,7 +390,7 @@ export function SchemaVisualizerPage({
return (
<div className="flex-1 flex flex-col min-h-0 bg-canvas">
{/* Toolbar */}
<div className="flex items-center gap-3 px-3 py-2 border-b border-border shrink-0 relative z-10">
<div className="flex items-center gap-3 px-3 py-2 border-b border-border shrink-0 relative z-20">
<div className="flex items-center gap-2">
{databases.length > 1 && (
<SelectDropdown
@@ -276,10 +426,90 @@ export function SchemaVisualizerPage({
<RotateCcw size={12} />
Reset Layout
</button>
{/* Export */}
<div className="flex items-center gap-2">
{exportError && (
<span className="text-[11px] text-red-400 max-w-56 truncate">
{exportError}
</span>
)}
<div className="relative">
<button
type="button"
ref={exportButtonRef}
onClick={() => setExportOpen((v) => !v)}
disabled={exporting}
className="flex items-center gap-1 px-2 py-1 text-xs rounded-md bg-surface border border-border text-text-muted hover:text-text hover:bg-surface-raised disabled:opacity-50"
>
{exporting ? (
<Loader2 size={12} className="animate-spin" />
) : (
<Download size={12} />
)}
{exporting ? "Exporting…" : "Export"}
<ChevronDown size={12} />
</button>
{exportOpen && !exporting && (
<div
ref={exportMenuRef}
className="absolute right-0 top-full mt-1 z-30 w-52 rounded-lg bg-surface border border-border shadow-lg py-2 px-2"
>
<div className="px-1 pb-1.5 text-[10px] text-text-muted uppercase tracking-wider">
Scope
</div>
<SelectDropdown
value={exportScope}
onChange={(v) =>
setExportScope(v as "schema" | "viewport")
}
options={[
{ value: "schema", label: "Entire Schema" },
{ value: "viewport", label: "Viewport" },
]}
aria-label="Export scope"
variant="pill"
/>
<div className="px-1 pb-1.5 pt-1.5 text-[10px] text-text-muted uppercase tracking-wider">
Background
</div>
<SelectDropdown
value={exportBackground}
onChange={(v) =>
setExportBackground(v as "opaque" | "transparent")
}
options={[
{ value: "opaque", label: "Opaque" },
{ value: "transparent", label: "Transparent" },
]}
aria-label="Export background"
variant="pill"
/>
<div className="border-t border-border my-1.5" />
{[
{ format: "png" as const, label: "PNG" },
{ format: "jpeg" as const, label: "JPEG" },
{ format: "svg" as const, label: "SVG" },
]
.filter((f) => !(transparent && f.format === "jpeg"))
.map(({ format, label }) => (
<button
key={format}
type="button"
onClick={() => handleExport(exportScope, format)}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left text-text hover:bg-surface-raised transition-colors cursor-pointer"
>
{label}
</button>
))}
</div>
)}
</div>
</div>
</div>
{/* Canvas */}
<div className="flex-1 min-h-0 relative">
<div className="flex-1 min-h-0 relative" ref={containerRef}>
{loading && (
<div className="absolute inset-0 flex items-center justify-center z-10 bg-canvas/80">
<p className="text-text-muted text-sm">Loading schema...</p>
+81 -3
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TabBar } from "./TabBar";
import { useDbViewerStore } from "../../stores/dbViewerStore";
@@ -11,9 +11,11 @@ describe("TabBar", () => {
useDbViewerStore.getState().reset();
});
it("shows empty state when no tabs", () => {
it("renders the fixed Query and Changes actions when no tabs are open", () => {
render(<TabBar />);
expect(screen.getByText(/No tables open/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /new query/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /changes queue/i })).toBeInTheDocument();
expect(screen.queryAllByRole("tab")).toHaveLength(0);
});
it("renders open tab names", () => {
@@ -36,6 +38,82 @@ describe("TabBar", () => {
expect(useDbViewerStore.getState().activeTabId).toBe(firstTabId);
});
it("opens a new query tab when Query is clicked", async () => {
const user = userEvent.setup();
render(<TabBar />);
await user.click(screen.getByRole("button", { name: /new query/i }));
const state = useDbViewerStore.getState();
expect(state.tabs).toHaveLength(1);
expect(state.tabs[0].tabType).toBe("query");
expect(state.activeTabId).toBe(state.tabs[0].id);
});
it("shows the pending change count and toggles the changes panel", async () => {
const user = userEvent.setup();
useDbViewerStore.getState().addChange({
type: "update",
schema: "public",
table: "users",
primaryKey: { id: 1 },
oldData: { name: "Bob" },
newData: { name: "Alice" },
});
useDbViewerStore.setState({ changesPanelExpanded: false });
render(<TabBar />);
const changesButton = screen.getByRole("button", { name: /changes queue/i });
expect(within(changesButton).getByText("1")).toBeInTheDocument();
await user.click(changesButton);
expect(useDbViewerStore.getState().changesPanelExpanded).toBe(true);
await user.click(changesButton);
expect(useDbViewerStore.getState().changesPanelExpanded).toBe(false);
});
it("renders a table icon on table tabs", () => {
useDbViewerStore.getState().openTab("public", "users");
render(<TabBar />);
expect(screen.getByTestId("tab-icon-table")).toBeInTheDocument();
expect(screen.queryByTestId("tab-icon-query")).not.toBeInTheDocument();
});
it("renders a query icon on query tabs", () => {
useDbViewerStore.getState().openQueryTab();
render(<TabBar />);
expect(screen.getByTestId("tab-icon-query")).toBeInTheDocument();
expect(screen.queryByTestId("tab-icon-table")).not.toBeInTheDocument();
});
it("shows the changes count as an icon with a badge", () => {
useDbViewerStore.getState().addChange({
type: "update",
schema: "public",
table: "users",
primaryKey: { id: 1 },
oldData: { name: "Bob" },
newData: { name: "Alice" },
});
useDbViewerStore.getState().addChange({
type: "insert",
schema: "public",
table: "posts",
newData: { title: "hi" },
});
render(<TabBar />);
const button = screen.getByRole("button", { name: /changes queue/i });
expect(button.querySelector("svg")).not.toBeNull();
expect(within(button).getByText("2")).toBeInTheDocument();
expect(screen.queryByText("Changes")).toBeNull();
});
it("hides the count badge when there are no pending changes", () => {
render(<TabBar />);
const button = screen.getByRole("button", { name: /changes queue/i });
expect(within(button).queryByText(/\d/)).toBeNull();
});
it("closes tab when close button clicked", async () => {
useDbViewerStore.getState().openTab("public", "users");
useDbViewerStore.getState().openTab("public", "posts", true);
+57 -9
View File
@@ -1,4 +1,4 @@
import { X } from "lucide-react";
import { ListChecks, Play, Table2, Terminal, X } from "lucide-react";
import { useDbViewerStore } from "../../stores/dbViewerStore";
export function TabBar() {
@@ -6,18 +6,21 @@ export function TabBar() {
const activeTabId = useDbViewerStore((state) => state.activeTabId);
const closeTab = useDbViewerStore((state) => state.closeTab);
const setActiveTab = useDbViewerStore((state) => state.setActiveTab);
if (tabs.length === 0) {
return (
<div className="flex h-10 items-center border-b border-border px-3 text-sm text-text-muted">
No tables open
</div>
const openQueryTab = useDbViewerStore((state) => state.openQueryTab);
const changesQueue = useDbViewerStore((state) => state.changesQueue);
const toggleChangesPanel = useDbViewerStore(
(state) => state.toggleChangesPanel,
);
}
const pendingCount = changesQueue.filter(
(c) => c.status === "pending",
).length;
return (
<div className="flex h-9 items-stretch border-b border-border">
{/* Left: open tabs (scrollable) */}
<div
className="flex flex-nowrap h-10 items-stretch overflow-x-auto border-b border-border"
className="flex flex-1 min-w-0 items-stretch overflow-x-auto"
role="tablist"
>
{tabs.map((tab) => {
@@ -36,6 +39,17 @@ export function TabBar() {
].join(" ")}
>
<span className="flex-1 text-left select-none">
{tab.tabType === "query" ? (
<Terminal
data-testid="tab-icon-query"
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
/>
) : (
<Table2
data-testid="tab-icon-table"
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
/>
)}
{tab.table}
</span>
<button
@@ -53,5 +67,39 @@ export function TabBar() {
);
})}
</div>
{/* Right: fixed actions */}
<div className="flex shrink-0 items-center gap-1.5 border-l border-border px-2">
<button
type="button"
onClick={openQueryTab}
aria-label="New query tab"
className="flex items-center gap-1.5 rounded-md bg-accent px-2.5 py-1 text-xs font-medium text-white transition-colors hover:bg-accent-hover cursor-pointer"
>
<Play className="h-3 w-3 fill-current" />
Query
</button>
<button
type="button"
onClick={() => {
if (changesQueue.length > 0) toggleChangesPanel();
}}
aria-label="Changes queue"
className={[
"flex items-center gap-1.5 rounded-md border border-border bg-surface px-2.5 py-1 text-xs font-medium transition-colors cursor-pointer",
pendingCount > 0
? "text-amber-400 border-amber-500/40 hover:bg-surface-raised"
: "text-text-muted hover:text-text hover:bg-surface-raised",
].join(" ")}
>
<ListChecks className="h-3.5 w-3.5" />
{pendingCount > 0 && (
<span className="inline-flex items-center justify-center min-w-[16px] h-4 rounded-full bg-amber-500 px-1 text-[10px] font-bold text-white">
{pendingCount}
</span>
)}
</button>
</div>
</div>
);
}
@@ -0,0 +1,317 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import type { ComponentProps } from "react";
import { TableControls, formatDuration } from "./TableControls";
import { TooltipProvider } from "../ui/Tooltip";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import type { ViewerTab } from "../../stores/dbViewerStore";
const columns = [
{
name: "id",
data_type: "integer",
is_nullable: false,
is_pk: true,
is_fk: false,
fk_ref: null,
default_value: null,
},
];
function makeTab(overrides: Partial<ViewerTab> = {}): ViewerTab {
return {
id: "tab-1",
schema: "public",
table: "users",
page: 1,
pageSize: 50,
loading: false,
error: null,
data: { columns, rows: [[1]], total_rows: 1, page: 1, page_size: 50 },
filterRules: [],
sortRules: [],
hiddenColumns: [],
smartSortApplied: true,
tabType: "table",
...overrides,
};
}
function seed(tabs: ViewerTab[], activeTabId: string) {
useDbViewerStore.getState().reset();
useDbViewerStore.setState({ tabs, activeTabId });
}
function renderControls(
props: Partial<ComponentProps<typeof TableControls>> = {},
) {
return render(
<TooltipProvider>
<TableControls
connectionId="c1"
schema="public"
table="users"
columns={columns}
rows={[[1]]}
hiddenColumns={new Set()}
onToggleColumn={() => {}}
onRefresh={() => {}}
filterRules={[]}
onFilterChange={() => {}}
sortRules={[]}
onSortChange={() => {}}
selectedCount={0}
selectedRows={[]}
onClearSelection={() => {}}
{...props}
/>
</TooltipProvider>,
);
}
describe("formatDuration", () => {
it("formats milliseconds with two decimals", () => {
expect(formatDuration(15)).toBe("15.00ms");
});
it("formats seconds with one decimal once past a second", () => {
expect(formatDuration(1500)).toBe("1.5s");
expect(formatDuration(3200)).toBe("3.2s");
});
it("formats minutes for long-running queries", () => {
expect(formatDuration(90000)).toBe("1.5m");
});
it("returns an empty string when there is no timing", () => {
expect(formatDuration(null)).toBe("");
expect(formatDuration(undefined)).toBe("");
});
});
describe("TableControls query variant", () => {
it("shows Export, Re-run, and Columns on the left", () => {
seed([makeTab({ tabType: "query" })], "tab-1");
renderControls({ variant: "query" });
expect(screen.getByLabelText(/export/i)).toBeInTheDocument();
expect(screen.getByLabelText(/re-run query/i)).toBeInTheDocument();
expect(screen.getByLabelText(/toggle columns/i)).toBeInTheDocument();
});
it("hides table-only controls and the queue", () => {
seed([makeTab({ tabType: "query" })], "tab-1");
renderControls({ variant: "query" });
expect(screen.queryByLabelText(/insert row/i)).toBeNull();
expect(screen.queryByLabelText(/auto-refresh/i)).toBeNull();
expect(screen.queryByLabelText(/column filters/i)).toBeNull();
expect(screen.queryByLabelText(/sort rules/i)).toBeNull();
expect(screen.queryByRole("button", { name: /action queue/i })).toBeNull();
});
it("shows the execution time from the result with a clock", () => {
seed(
[
makeTab({
tabType: "query",
data: {
columns,
rows: [],
total_rows: 0,
page: 1,
page_size: 50,
execution_time_ms: 15,
},
}),
],
"tab-1",
);
renderControls({ variant: "query" });
expect(screen.getByLabelText(/execution time/i)).toBeInTheDocument();
expect(screen.getByText("15.00ms")).toBeInTheDocument();
});
it("keeps the row count and pagination", () => {
seed(
[
makeTab({
tabType: "query",
data: {
columns,
rows: [[1]],
total_rows: 42,
page: 1,
page_size: 50,
execution_time_ms: 15,
},
}),
],
"tab-1",
);
renderControls({ variant: "query" });
expect(screen.getByText(/of 42/)).toBeInTheDocument();
expect(screen.getByLabelText(/next page/i)).toBeInTheDocument();
});
it("table variant has no queue button (moved to tab bar) and no execution time", () => {
seed(
[
makeTab({
data: {
columns,
rows: [[1]],
total_rows: 1,
page: 1,
page_size: 50,
execution_time_ms: 15,
},
}),
],
"tab-1",
);
renderControls({});
expect(
screen.queryByRole("button", { name: /action queue/i }),
).toBeNull();
expect(screen.getByLabelText(/toggle columns/i)).toBeInTheDocument();
expect(screen.queryByText("15.00ms")).toBeNull();
});
});
describe("TableControls", () => {
beforeEach(() => {
useDbViewerStore.getState().reset();
vi.clearAllMocks();
});
afterEach(() => {
vi.useRealTimers();
});
it("calls onRefresh when the refresh button is clicked", () => {
seed([makeTab()], "tab-1");
const onRefresh = vi.fn();
renderControls({ onRefresh });
fireEvent.click(screen.getByLabelText(/refresh table/i));
expect(onRefresh).toHaveBeenCalledTimes(1);
});
it("does not spin the refresh icon or show the pulse when idle", () => {
seed([makeTab()], "tab-1");
const { container } = renderControls();
expect(container.querySelector(".animate-spin")).toBeNull();
expect(screen.queryByTestId("refresh-pulse")).not.toBeInTheDocument();
});
it("spins the refresh icon and shows the pulse overlay while the tab is loading", () => {
seed([makeTab({ loading: true })], "tab-1");
const { container } = renderControls();
expect(container.querySelector(".animate-spin")).not.toBeNull();
expect(screen.getByTestId("refresh-pulse")).toBeInTheDocument();
});
it("shows the refreshing indicators when auto-refresh fires and clears them when done", () => {
vi.useFakeTimers();
seed([makeTab()], "tab-1");
const onRefresh = vi.fn(() => {
useDbViewerStore.setState((s) => ({
tabs: s.tabs.map((t) =>
t.id === "tab-1" ? { ...t, loading: true } : t,
),
}));
});
const { container } = renderControls({
onRefresh,
defaultRefreshRate: 5000,
});
expect(container.querySelector(".animate-spin")).toBeNull();
expect(screen.queryByTestId("refresh-pulse")).not.toBeInTheDocument();
act(() => {
vi.advanceTimersByTime(5000);
});
expect(onRefresh).toHaveBeenCalledTimes(1);
expect(container.querySelector(".animate-spin")).not.toBeNull();
expect(screen.getByTestId("refresh-pulse")).toBeInTheDocument();
// Once the fetch completes the indicators disappear
act(() => {
useDbViewerStore.setState((s) => ({
tabs: s.tabs.map((t) =>
t.id === "tab-1" ? { ...t, loading: false } : t,
),
}));
});
expect(container.querySelector(".animate-spin")).toBeNull();
expect(screen.queryByTestId("refresh-pulse")).not.toBeInTheDocument();
});
it("waits for an in-flight refresh to complete before restarting the timer", () => {
vi.useFakeTimers();
seed([makeTab()], "tab-1");
const onRefresh = vi.fn(() => {
useDbViewerStore.setState((s) => ({
tabs: s.tabs.map((t) =>
t.id === "tab-1" ? { ...t, loading: true } : t,
),
}));
});
renderControls({ onRefresh, defaultRefreshRate: 5000 });
// First interval fires the refresh
act(() => {
vi.advanceTimersByTime(5000);
});
expect(onRefresh).toHaveBeenCalledTimes(1);
// While the refresh is still in flight, the timer must NOT fire again
act(() => {
vi.advanceTimersByTime(15000);
});
expect(onRefresh).toHaveBeenCalledTimes(1);
// Once the refresh completes, a fresh countdown starts
act(() => {
useDbViewerStore.setState((s) => ({
tabs: s.tabs.map((t) =>
t.id === "tab-1" ? { ...t, loading: false } : t,
),
}));
});
act(() => {
vi.advanceTimersByTime(5000);
});
expect(onRefresh).toHaveBeenCalledTimes(2);
});
it("defers auto-refresh when the user switches tabs (resets the timer)", () => {
vi.useFakeTimers();
const onRefresh = vi.fn();
seed([makeTab(), makeTab({ id: "tab-2", table: "orders" })], "tab-1");
renderControls({ onRefresh, defaultRefreshRate: 5000 });
// Not yet a full interval
act(() => {
vi.advanceTimersByTime(4000);
});
expect(onRefresh).not.toHaveBeenCalled();
// User switches to another tab → countdown restarts
act(() => {
useDbViewerStore.setState({ activeTabId: "tab-2" });
});
act(() => {
vi.advanceTimersByTime(4000);
});
expect(onRefresh).not.toHaveBeenCalled();
// Full interval after the switch finally fires
act(() => {
vi.advanceTimersByTime(1000);
});
expect(onRefresh).toHaveBeenCalledTimes(1);
});
});
+160 -131
View File
@@ -112,6 +112,17 @@ function exportData(
URL.revokeObjectURL(url);
}
/**
* Format an execution duration using the most sensible unit:
* ms below a second, seconds (1 decimal) up to a minute, minutes beyond.
*/
export function formatDuration(ms: number | null | undefined): string {
if (ms == null) return "";
if (ms < 1000) return `${ms.toFixed(2)}ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
return `${(ms / 60_000).toFixed(1)}m`;
}
// ─── sub-components ─────────────────────────────────────
function DropdownMenu({
@@ -473,6 +484,8 @@ interface TableControlsProps {
selectedRows: unknown[][];
onClearSelection: () => void;
defaultRefreshRate?: number;
/** "table" = full table toolbar; "query" = export/refresh/columns + timing */
variant?: "table" | "query";
}
export function TableControls({
@@ -492,33 +505,38 @@ export function TableControls({
selectedRows,
onClearSelection,
defaultRefreshRate = 0,
variant = "table",
}: TableControlsProps) {
const isQuery = variant === "query";
const tabs = useDbViewerStore((s) => s.tabs);
const activeTabId = useDbViewerStore((s) => s.activeTabId);
const setPage = useDbViewerStore((s) => s.setPage);
const setPageSize = useDbViewerStore((s) => s.setPageSize);
const openTab = useDbViewerStore((s) => s.openTab);
const addChange = useDbViewerStore((s) => s.addChange);
const changesQueue = useDbViewerStore((s) => s.changesQueue);
const cancelChange = useDbViewerStore((s) => s.cancelChange);
const activeTab = tabs.find((t) => t.id === activeTabId);
const isRefreshing = activeTab?.loading ?? false;
// local state
const [filterOpen, setFilterOpen] = useState(false);
const [sortOpen, setSortOpen] = useState(false);
const [columnMenuOpen, setColumnMenuOpen] = useState(false);
const [exportOpen, setExportOpen] = useState(false);
const [queueOpen, setQueueOpen] = useState(false);
const [autoRefresh, setAutoRefresh] = useState(defaultRefreshRate);
const [autoRefreshOpen, setAutoRefreshOpen] = useState(false);
// auto-refresh timer
// Auto-refresh: a self-restarting timer that only counts down while the tab
// is idle. Fires a refresh, waits for it to complete (loading → false),
// then starts a fresh countdown. Also resets whenever the active tab changes
// so a freshly opened/reopened tab is not immediately refetched.
useEffect(() => {
if (autoRefresh === 0) return;
const id = setInterval(onRefresh, autoRefresh);
return () => clearInterval(id);
}, [autoRefresh, onRefresh]);
// While a refresh is in flight, wait for it to finish before counting down
if (isRefreshing) return;
const id = setTimeout(onRefresh, autoRefresh);
return () => clearTimeout(id);
}, [autoRefresh, onRefresh, isRefreshing, activeTabId]);
// pagination
const totalRows = activeTab?.data?.total_rows ?? rows.length;
@@ -559,10 +577,130 @@ export function TableControls({
setExportOpen(false);
};
const executionTimeMs = activeTab?.data?.execution_time_ms ?? null;
const refreshControl = (
<Tooltip
content={
isRefreshing
? isQuery
? "Running…"
: "Refreshing…"
: isQuery
? "Re-trigger query"
: "Refresh"
}
side="bottom"
>
<button
type="button"
onClick={onRefresh}
className="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised hover:text-text transition-colors cursor-pointer"
aria-label={isQuery ? "Re-run query" : "Refresh table"}
>
<RefreshCw
size={14}
className={isRefreshing ? "animate-spin text-accent" : ""}
/>
</button>
</Tooltip>
);
const exportControl = (
<div className="relative">
<Tooltip content="Export" side="bottom">
<button
type="button"
onClick={() => setExportOpen((v) => !v)}
className="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised hover:text-text transition-colors cursor-pointer"
aria-label="Export"
>
<Download size={14} />
</button>
</Tooltip>
<DropdownMenu open={exportOpen} setOpen={setExportOpen}>
{EXPORT_FORMATS.map((fmt) => (
<button
key={fmt.ext}
type="button"
onClick={() => handleExport(fmt.ext)}
className="w-full px-3 py-1.5 text-xs text-left text-text hover:bg-surface-raised transition-colors cursor-pointer"
>
{fmt.label}
</button>
))}
</DropdownMenu>
</div>
);
const columnsControl = (
<div className="relative">
<Tooltip content="Show/hide columns" side="bottom">
<button
type="button"
onClick={() => setColumnMenuOpen((v) => !v)}
className="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised hover:text-text transition-colors cursor-pointer"
aria-label="Toggle columns"
>
<Columns size={14} />
</button>
</Tooltip>
<DropdownMenu
open={columnMenuOpen}
setOpen={setColumnMenuOpen}
align={isQuery ? "left" : "right"}
>
<div className="px-2 py-1 text-[10px] text-text-muted uppercase tracking-wider">
Visible columns
</div>
<div className="max-h-64 overflow-y-auto">
{columns.map((col) => (
<button
key={col.name}
type="button"
onClick={() => onToggleColumn(col.name)}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left text-text hover:bg-surface-raised transition-colors cursor-pointer"
>
<span
className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 ${
hiddenColumns.has(col.name)
? "border-border bg-transparent"
: "border-accent bg-accent"
}`}
>
{!hiddenColumns.has(col.name) && (
<Check size={10} className="text-white" />
)}
</span>
<span className="truncate">{col.name}</span>
</button>
))}
</div>
</DropdownMenu>
</div>
);
return (
<div className="flex items-center gap-2 border-b border-border px-3 py-1.5 text-xs text-text-muted">
<div className="relative flex items-center gap-2 border-b border-border px-3 py-1.5 text-xs text-text-muted">
{/* refresh pulse: absolutely positioned so it never causes layout shifts */}
{isRefreshing && (
<div
data-testid="refresh-pulse"
aria-hidden="true"
className="absolute inset-0 pointer-events-none animate-toolbar-pulse bg-accent"
/>
)}
{/* ── left side ──────────────────────────────── */}
<div className="flex items-center gap-1">
{isQuery ? (
<>
{exportControl}
{refreshControl}
<div className="w-px h-4 bg-border mx-1" />
{columnsControl}
</>
) : (
<>
{/* Insert Row */}
<Tooltip content="Insert row" side="bottom">
<button
@@ -575,17 +713,7 @@ export function TableControls({
</button>
</Tooltip>
{/* Refresh */}
<Tooltip content="Refresh" side="bottom">
<button
type="button"
onClick={onRefresh}
className="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised hover:text-text transition-colors cursor-pointer"
aria-label="Refresh"
>
<RefreshCw size={14} />
</button>
</Tooltip>
{refreshControl}
{/* Auto-refresh */}
<div className="relative">
@@ -677,31 +805,9 @@ export function TableControls({
/>
</div>
{/* Export */}
<div className="relative">
<Tooltip content="Export" side="bottom">
<button
type="button"
onClick={() => setExportOpen((v) => !v)}
className="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised hover:text-text transition-colors cursor-pointer"
aria-label="Export"
>
<Download size={14} />
</button>
</Tooltip>
<DropdownMenu open={exportOpen} setOpen={setExportOpen}>
{EXPORT_FORMATS.map((fmt) => (
<button
key={fmt.ext}
type="button"
onClick={() => handleExport(fmt.ext)}
className="w-full px-3 py-1.5 text-xs text-left text-text hover:bg-surface-raised transition-colors cursor-pointer"
>
{fmt.label}
</button>
))}
</DropdownMenu>
</div>
{exportControl}
</>
)}
</div>
{/* ── spacer ──────────────────────────────────── */}
@@ -709,63 +815,18 @@ export function TableControls({
{/* ── right side ─────────────────────────────── */}
<div className="flex items-center gap-2">
{/* Action queue button */}
<div className="relative">
<button
type="button"
onClick={() => setQueueOpen((v) => !v)}
className={`relative flex items-center gap-1 rounded px-1.5 py-0.5 transition-colors cursor-pointer ${
changesQueue.some((c) => c.status === "pending")
? "text-amber-400 hover:bg-surface-raised"
: "text-text-muted hover:text-text hover:bg-surface-raised"
}`}
aria-label="Action queue"
{isQuery && executionTimeMs != null && (
<>
<span
className="flex items-center gap-1.5 tabular-nums"
aria-label="Execution time"
>
<span className="text-xs font-medium">Queue</span>
{changesQueue.filter((c) => c.status === "pending").length > 0 && (
<span className="inline-flex items-center justify-center min-w-[16px] h-4 rounded-full bg-amber-500 text-[10px] font-bold text-white px-1">
{changesQueue.filter((c) => c.status === "pending").length}
<Clock size={12} className="text-text-muted" />
{formatDuration(executionTimeMs)}
</span>
<div className="w-px h-4 bg-border" />
</>
)}
</button>
<DropdownMenu open={queueOpen} setOpen={setQueueOpen} align="right">
<div className="px-2 py-1 text-[10px] text-text-muted uppercase tracking-wider">
Changes Queue ({changesQueue.filter((c) => c.status === "pending").length} pending)
</div>
<div className="max-h-64 overflow-y-auto">
{changesQueue.length === 0 && (
<div className="px-3 py-2 text-xs text-text-muted">No changes queued</div>
)}
{changesQueue.map((item) => (
<div
key={item.id}
className={`flex items-center justify-between px-3 py-1.5 text-xs ${
item.status === "pending" ? "text-text" : "text-text-muted/50"
}`}
>
<span className="truncate flex-1">
<span className={`inline-block w-2 h-2 rounded-full mr-1.5 ${
item.status === "pending" ? "bg-amber-500"
: item.status === "committed" ? "bg-emerald-500"
: "bg-red-500"
}`} />
{item.type.toUpperCase()} {item.table}
{item.description && <span className="ml-1 text-text-muted/50"> {item.description}</span>}
</span>
{item.status === "pending" && (
<button
type="button"
onClick={() => cancelChange(item.id)}
className="text-text-muted hover:text-red-400 ml-2 shrink-0 cursor-pointer"
>
<X size={12} />
</button>
)}
</div>
))}
</div>
</DropdownMenu>
</div>
{/* Selected count + bulk actions */}
{selectedCount > 0 && (
<>
@@ -791,39 +852,7 @@ export function TableControls({
</>
)}
{/* Columns toggle */}
<div className="relative">
<Tooltip content="Show/hide columns" side="bottom">
<button
type="button"
onClick={() => setColumnMenuOpen((v) => !v)}
className="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised hover:text-text transition-colors cursor-pointer"
aria-label="Toggle columns"
>
<Columns size={14} />
</button>
</Tooltip>
<DropdownMenu open={columnMenuOpen} setOpen={setColumnMenuOpen} align="right">
<div className="px-2 py-1 text-[10px] text-text-muted uppercase tracking-wider">Visible columns</div>
<div className="max-h-64 overflow-y-auto">
{columns.map((col) => (
<button
key={col.name}
type="button"
onClick={() => onToggleColumn(col.name)}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left text-text hover:bg-surface-raised transition-colors cursor-pointer"
>
<span className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 ${
hiddenColumns.has(col.name) ? "border-border bg-transparent" : "border-accent bg-accent"
}`}>
{!hiddenColumns.has(col.name) && <Check size={10} className="text-white" />}
</span>
<span className="truncate">{col.name}</span>
</button>
))}
</div>
</DropdownMenu>
</div>
{!isQuery && columnsControl}
<div className="w-px h-4 bg-border" />
@@ -0,0 +1,40 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { DestructiveQueryDialog } from "./DestructiveQueryDialog";
describe("DestructiveQueryDialog", () => {
it("renders warning message with the SQL shown", () => {
render(
<DestructiveQueryDialog open={true} query="DROP TABLE users" onConfirm={() => {}} onCancel={() => {}} />
);
expect(screen.getByText(/destructive/i)).toBeInTheDocument();
expect(screen.getByText(/DROP TABLE users/)).toBeInTheDocument();
});
it("calls onConfirm when 'Execute' is clicked", () => {
const onConfirm = vi.fn();
render(
<DestructiveQueryDialog open={true} query="DELETE FROM users" onConfirm={onConfirm} onCancel={() => {}} />
);
fireEvent.click(screen.getByText("Execute"));
expect(onConfirm).toHaveBeenCalledTimes(1);
});
it("calls onCancel when 'Cancel' is clicked", () => {
const onCancel = vi.fn();
render(
<DestructiveQueryDialog open={true} query="DROP TABLE users" onConfirm={() => {}} onCancel={onCancel} />
);
fireEvent.click(screen.getByText("Cancel"));
expect(onCancel).toHaveBeenCalledTimes(1);
});
it("truncates long queries in the dialog", () => {
const longQuery = "DROP TABLE " + "x".repeat(500);
render(
<DestructiveQueryDialog open={true} query={longQuery} onConfirm={() => {}} onCancel={() => {}} />
);
const displayed = screen.getByText(/DROP TABLE/);
expect(displayed.textContent!.length).toBeLessThan(longQuery.length + 20);
});
});
@@ -0,0 +1,40 @@
import { AlertTriangle } from "lucide-react";
import { Button } from "../ui/Button";
interface DestructiveQueryDialogProps {
open: boolean;
query: string;
onConfirm: () => void;
onCancel: () => void;
}
export function DestructiveQueryDialog({ open, query, onConfirm, onCancel }: DestructiveQueryDialogProps) {
if (!open) return null;
const truncated = query.length > 200 ? query.slice(0, 200) + "..." : query;
return (
<div className="fixed inset-0 bg-canvas/60 backdrop-blur-sm flex items-center justify-center z-50">
<div className="glass rounded-2xl p-6 shadow-2xl ring-1 ring-white/10 max-w-md w-full">
<div className="flex items-start gap-3 mb-4">
<AlertTriangle size={20} className="text-amber-400 shrink-0 mt-0.5" />
<div>
<h3 className="font-heading text-text text-lg">Destructive Query</h3>
<p className="text-sm text-text-muted mt-1">
This query will modify your database. This action cannot be undone.
</p>
</div>
</div>
<div className="bg-canvas border border-border rounded-lg p-3 mb-4">
<code className="text-xs text-text-muted font-mono break-all">{truncated}</code>
</div>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={onCancel}>Cancel</Button>
<Button onClick={onConfirm} className="!bg-red-500 hover:!bg-red-600 !border-red-500">Execute</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,89 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { QueryEditor } from "./QueryEditor";
import { editor as monacoEditor } from "monaco-editor";
// Monaco editor loads from CDN — mock it for tests to avoid network dependency
const { registeredActions } = vi.hoisted(() => ({
registeredActions: [] as Array<{ id: string; keybindings: number[]; run: () => void }>,
}));
const { editorOptions } = vi.hoisted(() => ({ editorOptions: [] as Array<Record<string, unknown>> }));
// monaco-editor's global re-measure (font metrics) — stub so tests stay light
vi.mock("monaco-editor", () => ({
editor: { remeasureFonts: vi.fn() },
}));
vi.mock("@monaco-editor/react", () => ({
default: ({ value, onChange, onMount, options }: any) => {
editorOptions.push(options);
if (onMount) {
onMount({
addAction: (action: any) => registeredActions.push(action),
getValue: () => value,
setValue: (v: string) => onChange?.(v),
focus: vi.fn(),
});
}
return (
<div data-testid="monaco-editor">
<textarea
data-testid="monaco-textarea"
value={value}
onChange={(e) => onChange?.(e.target.value)}
/>
</div>
);
},
}));
describe("QueryEditor", () => {
beforeEach(() => {
registeredActions.length = 0;
vi.mocked(monacoEditor.remeasureFonts).mockClear();
});
it("renders a textarea editor", () => {
render(<QueryEditor value="SELECT 1" onChange={() => {}} onRun={() => {}} />);
expect(screen.getByTestId("monaco-editor")).toBeInTheDocument();
});
it("displays the provided value", () => {
render(<QueryEditor value="SELECT * FROM users" onChange={() => {}} onRun={() => {}} />);
const textarea = screen.getByTestId("monaco-textarea");
expect(textarea).toHaveValue("SELECT * FROM users");
});
it("calls onChange when text changes", () => {
const onChange = vi.fn();
render(<QueryEditor value="" onChange={onChange} onRun={() => {}} />);
fireEvent.change(screen.getByTestId("monaco-textarea"), { target: { value: "SELECT 1" } });
expect(onChange).toHaveBeenCalledWith("SELECT 1");
});
it("registers a Cmd+Enter action that runs the query", () => {
const onRun = vi.fn();
render(<QueryEditor value="SELECT 1" onChange={() => {}} onRun={onRun} />);
expect(registeredActions).toHaveLength(1);
expect(registeredActions[0].keybindings).toEqual([2048 | 3]);
registeredActions[0].run();
expect(onRun).toHaveBeenCalledTimes(1);
});
it("passes a placeholder option to the editor", () => {
editorOptions.length = 0;
render(<QueryEditor value="" onChange={() => {}} onRun={() => {}} />);
expect(editorOptions[0]?.placeholder).toMatch(/Enter your SQL query/i);
});
it("re-measures fonts after mount so the cursor stays aligned", () => {
render(<QueryEditor value="" onChange={() => {}} onRun={() => {}} />);
expect(monacoEditor.remeasureFonts).toHaveBeenCalled();
});
it("wraps the editor without padding, border, or rounding", () => {
render(<QueryEditor value="" onChange={() => {}} onRun={() => {}} />);
const wrapper = screen.getByTestId("query-editor");
expect(wrapper.className).not.toMatch(/rounded|border|p-\d/);
});
});
+87
View File
@@ -0,0 +1,87 @@
import { useCallback } from "react";
import Editor, { type OnMount, type BeforeMount } from "@monaco-editor/react";
import * as monaco from "monaco-editor";
interface QueryEditorProps {
value: string;
onChange: (value: string) => void;
onRun: () => void;
readOnly?: boolean;
}
export function QueryEditor({
value,
onChange,
onRun,
readOnly = false,
}: QueryEditorProps) {
const handleMount: OnMount = useCallback(
(editor) => {
editor.addAction({
id: "run-query",
label: "Run Query",
keybindings: [2048 | 3], // Cmd/Ctrl+Enter
run: () => onRun(),
});
editor.focus();
// Custom fonts (@fontsource Space Mono) load asynchronously. Monaco
// measures glyph widths at creation, so if the font lands after that the
// cursor/selection drift rightward the further along the line you are.
// Re-measure now (fonts may already be ready) and again once fonts load.
const reMeasure = () => monaco.editor.remeasureFonts();
reMeasure();
try {
void document.fonts?.load('13px "Space Mono"').then(() => {
requestAnimationFrame(reMeasure);
// WebKit can settle a frame late; re-measure once more to be safe
setTimeout(reMeasure, 200);
});
} catch {
// fonts API unavailable — nothing more we can do
}
},
[onRun],
);
// Transparent editor background so the app's canvas shows through
const handleBeforeMount: BeforeMount = useCallback((monaco) => {
monaco.editor.defineTheme("gridline-sql", {
base: "vs-dark",
inherit: true,
rules: [],
colors: {
"editor.background": "#00000000",
"editorGutter.background": "#00000000",
"editor.lineHighlightBackground": "#ffffff08",
"editorLineNumber.foreground": "#5b5b5e",
"editorLineNumber.activeForeground": "#a1a1a6",
},
});
}, []);
return (
<div className="h-full min-h-0" data-testid="query-editor">
<Editor
height="100%"
language="sql"
theme="gridline-sql"
beforeMount={handleBeforeMount}
value={value}
onChange={(v) => onChange(v ?? "")}
onMount={handleMount}
options={{
minimap: { enabled: false },
fontSize: 13,
fontFamily: "'Space Mono', 'Fira Code', monospace",
lineNumbers: "on",
scrollBeyondLastLine: false,
wordWrap: "off",
readOnly,
placeholder: "Enter your SQL query…",
automaticLayout: true,
}}
/>
</div>
);
}
+139
View File
@@ -0,0 +1,139 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { QueryToolbar, queryShortcut } from "./QueryToolbar";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { TooltipProvider } from "../ui/Tooltip";
function renderToolbar(props: {
onRun?: () => void;
onFormat?: () => void;
dbType?: "postgresql" | "mysql" | "sqlite" | "redis";
}) {
return render(
<TooltipProvider>
<QueryToolbar
onRun={props.onRun ?? (() => {})}
onFormat={props.onFormat ?? (() => {})}
dbType={props.dbType}
/>
</TooltipProvider>,
);
}
describe("queryShortcut", () => {
afterEach(() => {
Object.defineProperty(navigator, "platform", {
value: "",
configurable: true,
});
});
it("uses the command symbol and enter glyph on mac", () => {
expect(queryShortcut("MacIntel")).toEqual({ mod: "⌘", enter: "⏎" });
});
it("uses Ctrl + Enter on other platforms", () => {
expect(queryShortcut("Win32")).toEqual({ mod: "Ctrl", enter: "Enter" });
expect(queryShortcut("Linux x86_64")).toEqual({
mod: "Ctrl",
enter: "Enter",
});
});
it("renders the mac shortcut in the run tooltip", async () => {
Object.defineProperty(navigator, "platform", {
value: "MacIntel",
configurable: true,
});
renderToolbar({});
fireEvent.mouseEnter(
screen.getByRole("button", { name: /run query/i }).parentElement!,
);
await waitFor(() => expect(screen.getByText("⌘")).toBeInTheDocument());
expect(screen.getByText("⏎")).toBeInTheDocument();
});
it("renders Ctrl + Enter in the run tooltip on non-mac", async () => {
Object.defineProperty(navigator, "platform", {
value: "Linux x86_64",
configurable: true,
});
renderToolbar({});
fireEvent.mouseEnter(
screen.getByRole("button", { name: /run query/i }).parentElement!,
);
await waitFor(() => expect(screen.getByText("Ctrl")).toBeInTheDocument());
expect(screen.getByText("Enter")).toBeInTheDocument();
});
});
describe("QueryToolbar", () => {
beforeEach(() => {
useDbViewerStore.getState().reset();
});
it("renders Run Query and the format icon button", () => {
renderToolbar({});
expect(
screen.getByRole("button", { name: /run query/i }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /auto format/i }),
).toBeInTheDocument();
});
it("shows the SQL dialect on the right", () => {
renderToolbar({ dbType: "postgresql" });
expect(screen.getByText("PostgreSQL")).toBeInTheDocument();
renderToolbar({ dbType: "mysql" });
expect(screen.getByText("MySQL")).toBeInTheDocument();
});
it("hides the dialect badge when no db type is known", () => {
renderToolbar({});
expect(screen.queryByText(/postgresql|mysql|sqlite|redis/i)).toBeNull();
});
it("runs the query when Run Query is clicked", () => {
const onRun = vi.fn();
renderToolbar({ onRun });
fireEvent.click(screen.getByRole("button", { name: /run query/i }));
expect(onRun).toHaveBeenCalledTimes(1);
});
it("calls onFormat when the format icon is clicked", () => {
const onFormat = vi.fn();
renderToolbar({ onFormat });
fireEvent.click(screen.getByRole("button", { name: /auto format/i }));
expect(onFormat).toHaveBeenCalledTimes(1);
});
it("starts with an unfilled play icon", () => {
renderToolbar({});
const playSvg = screen
.getByRole("button", { name: /run query/i })
.querySelector("svg");
expect(playSvg).toHaveAttribute("fill", "none");
});
it("shows the pulse while the active query tab is loading", () => {
useDbViewerStore.getState().openQueryTab();
useDbViewerStore.setState((s) => ({
tabs: s.tabs.map((t) => ({ ...t, loading: true })),
}));
renderToolbar({});
expect(screen.getByTestId("query-run-pulse")).toBeInTheDocument();
});
it("hides the pulse when the query is idle", () => {
useDbViewerStore.getState().openQueryTab();
renderToolbar({});
expect(screen.queryByTestId("query-run-pulse")).toBeNull();
});
it("renders the format action as an icon only (no text label)", () => {
renderToolbar({});
const button = screen.getByRole("button", { name: /auto format/i });
expect(button.textContent?.trim()).toBe("");
});
});
+110
View File
@@ -0,0 +1,110 @@
import { Play, Wand2 } from "lucide-react";
import { Tooltip } from "../ui/Tooltip";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import type { DbType } from "../../lib/types";
const DB_TYPE_LABELS: Record<DbType, string> = {
postgresql: "PostgreSQL",
mysql: "MySQL",
sqlite: "SQLite",
redis: "Redis",
};
/**
* Resolve the run-query modifier key for the given platform string
* (from `navigator.platform`). Mac platforms show + the return glyph,
* everything else shows Ctrl + Enter.
*/
export function queryShortcut(platform: string): {
mod: string;
enter: string;
} {
const isMac = /Mac|iPhone|iPad/.test(platform);
return isMac
? { mod: "⌘", enter: "⏎" }
: { mod: "Ctrl", enter: "Enter" };
}
interface QueryToolbarProps {
onRun: () => void;
onFormat: () => void;
dbType?: DbType;
readOnly?: boolean;
}
export function QueryToolbar({
onRun,
onFormat,
dbType,
readOnly = false,
}: QueryToolbarProps) {
const tabs = useDbViewerStore((s) => s.tabs);
const activeTabId = useDbViewerStore((s) => s.activeTabId);
const isRunning = tabs.find((t) => t.id === activeTabId)?.loading ?? false;
const shortcut = queryShortcut(
typeof navigator !== "undefined" ? navigator.platform : "",
);
return (
<div className="relative flex items-center gap-2 border-b border-border px-3 py-1.5 text-xs text-text-muted">
{/* run pulse: mirrors the table toolbar refresh pulse, absolutely positioned so it never shifts layout */}
{isRunning && (
<div
data-testid="query-run-pulse"
aria-hidden="true"
className="absolute inset-0 pointer-events-none animate-toolbar-pulse bg-accent"
/>
)}
<div className="flex items-center gap-1">
{/* Run Query: outline play that fills on hover; tooltip (delayed) reveals the shortcut */}
<Tooltip
content={
<span className="inline-flex items-center gap-1">
<kbd className="rounded border border-border bg-surface px-1 font-mono text-[10px] leading-none">
{shortcut.mod}
</kbd>
<span className="text-text-muted">+</span>
<kbd className="rounded border border-border bg-surface px-1 font-mono text-[10px] leading-none">
{shortcut.enter}
</kbd>
</span>
}
side="bottom"
>
<button
type="button"
onClick={onRun}
disabled={readOnly}
className="group flex items-center gap-1.5 rounded-md bg-accent px-2.5 py-1 font-medium text-white transition-colors hover:bg-accent-hover cursor-pointer disabled:cursor-not-allowed disabled:opacity-50"
aria-label="Run query"
>
<Play className="h-3.5 w-3.5 transition-[fill] duration-150 group-hover:fill-current" />
<span>Run Query</span>
</button>
</Tooltip>
{/* Auto format: icon only with tooltip */}
<Tooltip content="Auto format query" side="bottom">
<button
type="button"
onClick={onFormat}
disabled={readOnly}
className="flex items-center rounded px-2 py-1.5 hover:bg-surface-raised hover:text-text transition-colors cursor-pointer disabled:cursor-not-allowed disabled:opacity-50"
aria-label="Auto format query"
>
<Wand2 className="h-3.5 w-3.5" />
</button>
</Tooltip>
</div>
{dbType && (
<div className="ml-auto">
<span className="rounded-md border border-border px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide text-text-muted">
{DB_TYPE_LABELS[dbType]}
</span>
</div>
)}
</div>
);
}
@@ -34,4 +34,17 @@ describe("FolderBreadcrumb", () => {
await userEvent.click(screen.getByText("All Connections"));
expect(fn).toHaveBeenCalledWith(null);
});
it("shows 'Showing Search Results' when hasSearch is true", () => {
render(<FolderBreadcrumb folders={folders} activeFolderId={null} onNavigate={() => {}} hasSearch />);
expect(screen.getByText("Showing Search Results")).toBeInTheDocument();
expect(screen.queryByText("All Connections")).not.toBeInTheDocument();
});
it("calls onClearSearch when Clear is clicked", async () => {
const fn = vi.fn();
render(<FolderBreadcrumb folders={folders} activeFolderId={null} onNavigate={() => {}} hasSearch onClearSearch={fn} />);
await userEvent.click(screen.getByText("Clear"));
expect(fn).toHaveBeenCalledTimes(1);
});
});
+31 -2
View File
@@ -1,16 +1,45 @@
import type { Folder } from "../../lib/types";
import { ChevronRight, Home } from "lucide-react";
import { ChevronRight, Home, Search, X } from "lucide-react";
import { getFolderPath } from "../../lib/utils";
interface FolderBreadcrumbProps {
folders: Folder[];
activeFolderId: string | null;
onNavigate: (folderId: string | null) => void;
hasSearch?: boolean;
onClearSearch?: () => void;
}
export function FolderBreadcrumb({ folders, activeFolderId, onNavigate }: FolderBreadcrumbProps) {
export function FolderBreadcrumb({
folders,
activeFolderId,
onNavigate,
hasSearch = false,
onClearSearch,
}: FolderBreadcrumbProps) {
const path = getFolderPath(folders, activeFolderId);
if (hasSearch) {
return (
<nav className="flex items-center gap-1 text-sm text-text-muted">
<span className="flex items-center gap-1 px-2 py-1 text-text">
<Search size={14} />
<span>Showing Search Results</span>
</span>
{onClearSearch && (
<button
onClick={onClearSearch}
aria-label="Clear search"
title="Clear search"
className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-text-muted hover:text-text hover:bg-surface-raised transition-colors cursor-pointer"
>
<X size={14} /> Clear
</button>
)}
</nav>
);
}
return (
<nav className="flex items-center gap-1 text-sm text-text-muted">
<button
@@ -186,6 +186,60 @@ describe("VirtualDataGrid", () => {
expect(screen.getByText(/2 keys/)).toBeInTheDocument();
});
it("keeps header width to content and borders the last column", () => {
mockGetTotalSize.mockReturnValue(0);
mockGetVirtualItems.mockReturnValue([]);
const { container } = render(
<VirtualDataGrid
connectionId="conn-1"
schema="public"
rows={[]}
columns={mockColumns}
hiddenColumns={new Set()}
selectedRows={new Set()}
onToggleRow={() => {}}
onToggleAll={() => {}}
/>,
);
// Header must span exactly the total column width (40 checkbox + 2 × 200),
// not stretch across the empty area to the right of the last column.
const header = container.querySelector(".sticky > div");
expect(header).not.toBeNull();
expect((header as HTMLElement).style.width).toBe("440px");
expect((header as HTMLElement).style.minWidth).toBe("");
// The last column header keeps a right border, matching body cells.
const headerCells = container.querySelectorAll(".sticky > div:first-child > div");
const lastCol = headerCells[headerCells.length - 1];
expect(lastCol.className).toContain("border-r");
expect(lastCol.className).not.toContain("border-r-0");
});
it("hides the select-all checkbox and header when no columns are present", () => {
mockGetTotalSize.mockReturnValue(0);
mockGetVirtualItems.mockReturnValue([]);
const { container } = render(
<VirtualDataGrid
connectionId="conn-1"
schema="public"
rows={[]}
columns={[]}
hiddenColumns={new Set()}
selectedRows={new Set()}
onToggleRow={() => {}}
onToggleAll={() => {}}
/>,
);
// No table open → no select-all checkbox, no header bar, no empty-state message.
expect(screen.queryAllByRole("checkbox").length).toBe(0);
expect(container.querySelector(".sticky")).toBeNull();
expect(screen.queryByText(/no rows/i)).not.toBeInTheDocument();
});
it("has resize handles on column headers", () => {
mockGetTotalSize.mockReturnValue(0);
mockGetVirtualItems.mockReturnValue([]);
+13 -6
View File
@@ -35,6 +35,7 @@ export function VirtualDataGrid({
const parentRef = useRef<HTMLDivElement>(null);
const visibleColumns = columns.filter((c) => !hiddenColumns.has(c.name));
const hasColumns = columns.length > 0;
const allSelected = rows.length > 0 && selectedRows.size === rows.length;
const selectAllRef = useRef<HTMLInputElement>(null);
@@ -62,7 +63,9 @@ export function VirtualDataGrid({
);
// Total width for horizontal scroll support
const totalWidth = 40 + visibleColumns.reduce((sum, c) => sum + getWidth(c.name), 0);
const totalWidth =
(hasColumns ? 40 : 0) +
visibleColumns.reduce((sum, c) => sum + getWidth(c.name), 0);
const resizeRef = useRef<{ col: string; startX: number; startWidth: number } | null>(null);
@@ -211,9 +214,10 @@ export function VirtualDataGrid({
return (
<div ref={parentRef} className="overflow-auto h-full" style={{ overscrollBehavior: "none" }}>
{/* ── sticky header ── */}
{/* ── sticky header (hidden when no columns/table open) ── */}
{hasColumns && (
<div className="sticky top-0 z-10">
<div className="flex items-center border-b border-border bg-canvas" style={{ minWidth: totalWidth }}>
<div className="flex items-center border-b border-border bg-canvas" style={{ width: totalWidth }}>
<div style={{ width: 40, minWidth: 40 }} className="px-2 py-2 flex items-center justify-center border-r border-border self-stretch">
<input
ref={selectAllRef}
@@ -226,7 +230,7 @@ export function VirtualDataGrid({
{visibleColumns.map((col) => (
<div
key={col.name}
className="group relative px-3 py-2 font-heading text-text-muted border-r border-border last:border-r-0 self-stretch"
className="group relative px-3 py-2 font-heading text-text-muted border-r border-border self-stretch"
style={{ width: getWidth(col.name), flexShrink: 0 }}
>
<div className="truncate flex items-center gap-1">
@@ -246,9 +250,10 @@ export function VirtualDataGrid({
))}
</div>
</div>
)}
{/* ── virtual body ── */}
{rows.length === 0 ? (
{/* ── virtual body (hidden when no columns/table open) ── */}
{!hasColumns ? null : rows.length === 0 ? (
<div className="py-12 text-center text-sm text-text-muted">
No rows in result set
</div>
@@ -279,6 +284,7 @@ export function VirtualDataGrid({
transform: `translateY(${virtualRow.start}px)`,
}}
>
{hasColumns && (
<div style={{ width: 40, minWidth: 40 }} className="flex items-center justify-center border-r border-border self-stretch">
<input
type="checkbox"
@@ -287,6 +293,7 @@ export function VirtualDataGrid({
className="w-3.5 h-3.5 rounded border-border cursor-pointer accent-accent"
/>
</div>
)}
{visibleColumns.map((col) => renderCell(col, row, virtualRow.index))}
</div>
);
-5
View File
@@ -21,9 +21,4 @@ describe("ActionRow", () => {
await userEvent.click(screen.getByText(/settings/i));
expect(useUiStore.getState().activeView).toBe("settings");
});
it("Tags button switches to settings view", async () => {
render(<ActionRow />);
await userEvent.click(screen.getByText(/^tags$/i));
expect(useUiStore.getState().activeView).toBe("settings");
});
});
+6 -8
View File
@@ -1,8 +1,10 @@
import { useEffect, useRef, useState } from "react";
import { Plus, Settings as SettingsIcon, Tag, Filter, FolderPlus, Trash2, Check, X, ChevronDown } from "lucide-react";
import { Plus, Settings as SettingsIcon, FolderPlus, Trash2, Check, X, ChevronDown } from "lucide-react";
import { Button } from "../ui/Button";
import { useUiStore } from "../../stores/uiStore";
import { ImportExportMenu } from "./ImportExportMenu";
import { TagFilterDropdown } from "../tags/TagFilterDropdown";
import { DbTypeFilterDropdown } from "./DbTypeFilterDropdown";
interface ActionRowProps {
onImport?: () => void;
@@ -13,7 +15,7 @@ interface ActionRowProps {
visibleItemIds?: string[];
}
export function ActionRow({ onImport, onExport, onNewFolder, onFilters, onDeleteSelected, visibleItemIds = [] }: ActionRowProps) {
export function ActionRow({ onImport, onExport, onNewFolder, onFilters: _onFilters, onDeleteSelected, visibleItemIds = [] }: ActionRowProps) {
const setActiveView = useUiStore((s) => s.setActiveView);
const selectedItemIds = useUiStore((s) => s.selectedItemIds);
const selectAllItems = useUiStore((s) => s.selectAllItems);
@@ -44,12 +46,8 @@ export function ActionRow({ onImport, onExport, onNewFolder, onFilters, onDelete
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" className="text-xs" onClick={() => setActiveView("settings")}>
<Tag size={14} /> Tags
</Button>
<Button variant="ghost" className="text-xs" onClick={onFilters ?? (() => {})}>
<Filter size={14} /> Filters
</Button>
<TagFilterDropdown />
<DbTypeFilterDropdown />
<Button variant="ghost" className="text-xs border-0" onClick={onNewFolder ?? (() => {})}>
<FolderPlus size={14} /> New Folder
</Button>
@@ -0,0 +1,78 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { DbTypeFilterDropdown } from "./DbTypeFilterDropdown";
import { useUiStore } from "../../stores/uiStore";
vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() }));
describe("DbTypeFilterDropdown", () => {
beforeEach(() => {
useUiStore.setState({ activeDbTypes: [], activeEnvironment: null });
});
it("renders a button with Filter icon", () => {
render(<DbTypeFilterDropdown />);
expect(screen.getByText("Filters")).toBeInTheDocument();
});
it("opens dropdown and shows 4 DB types", () => {
render(<DbTypeFilterDropdown />);
fireEvent.click(screen.getByText("Filters"));
expect(screen.getByText("PostgreSQL")).toBeInTheDocument();
expect(screen.getByText("MySQL")).toBeInTheDocument();
expect(screen.getByText("SQLite")).toBeInTheDocument();
expect(screen.getByText("Redis")).toBeInTheDocument();
});
it("toggles DB type filter on click", () => {
render(<DbTypeFilterDropdown />);
fireEvent.click(screen.getByText("Filters"));
fireEvent.click(screen.getByText("PostgreSQL"));
expect(useUiStore.getState().activeDbTypes).toContain("postgresql");
});
it("clears all filters with 'Clear all' button", () => {
useUiStore.setState({ activeDbTypes: ["postgresql", "sqlite"] });
render(<DbTypeFilterDropdown />);
fireEvent.click(screen.getByText("Filters"));
fireEvent.click(screen.getByText("Clear all"));
expect(useUiStore.getState().activeDbTypes).toEqual([]);
});
it("shows environment select in dropdown", () => {
render(<DbTypeFilterDropdown />);
fireEvent.click(screen.getByText("Filters"));
expect(screen.getByLabelText("Environment filter")).toBeInTheDocument();
});
it("selects environment via the select", () => {
render(<DbTypeFilterDropdown />);
fireEvent.click(screen.getByText("Filters"));
fireEvent.click(screen.getByLabelText("Environment filter"));
fireEvent.click(screen.getByText("Production"));
expect(useUiStore.getState().activeEnvironment).toBe("production");
});
it("'All' option clears environment filter", () => {
useUiStore.setState({ activeEnvironment: "production" });
render(<DbTypeFilterDropdown />);
fireEvent.click(screen.getByText("Filters"));
fireEvent.click(screen.getByLabelText("Environment filter"));
fireEvent.click(screen.getByText("All"));
expect(useUiStore.getState().activeEnvironment).toBeNull();
});
it("shows 'None' option for connections without environment", () => {
render(<DbTypeFilterDropdown />);
fireEvent.click(screen.getByText("Filters"));
fireEvent.click(screen.getByLabelText("Environment filter"));
fireEvent.click(screen.getByText("None"));
expect(useUiStore.getState().activeEnvironment).toBe("none");
});
it("environment filter counts toward badge", () => {
useUiStore.setState({ activeDbTypes: [], activeEnvironment: "staging" });
render(<DbTypeFilterDropdown />);
expect(screen.getByText("1")).toBeInTheDocument();
});
});
@@ -0,0 +1,117 @@
import { useEffect, useRef, useState } from "react";
import { Filter } from "lucide-react";
import { useUiStore } from "../../stores/uiStore";
import { SelectDropdown } from "../ui/SelectDropdown";
import type { DbType } from "../../lib/types";
const DB_TYPES: { value: DbType; label: string }[] = [
{ value: "postgresql", label: "PostgreSQL" },
{ value: "mysql", label: "MySQL" },
{ value: "sqlite", label: "SQLite" },
{ value: "redis", label: "Redis" },
];
export function DbTypeFilterDropdown() {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const activeDbTypes = useUiStore((s) => s.activeDbTypes);
const toggleDbType = useUiStore((s) => s.toggleDbType);
const clearFilters = useUiStore((s) => s.clearFilters);
const activeEnvironment = useUiStore((s) => s.activeEnvironment);
const setEnvironment = useUiStore((s) => s.setEnvironment);
const activeCount = activeDbTypes.length + (activeEnvironment ? 1 : 0);
const hasActiveFilters = activeCount > 0;
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open]);
const handleClearAll = () => {
clearFilters();
setOpen(false);
};
return (
<div className="relative inline-block" ref={containerRef}>
<button
onClick={() => setOpen((o) => !o)}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium border transition-all cursor-pointer ${
hasActiveFilters
? "bg-accent/10 border-accent text-accent"
: "bg-transparent border-border text-text-muted hover:text-text hover:border-border-hover"
}`}
>
<Filter size={14} />
<span>Filters</span>
{hasActiveFilters && (
<span className="ml-0.5 flex items-center justify-center min-w-[1.125rem] h-[1.125rem] px-1 rounded-full text-[10px] bg-accent text-white">
{activeCount}
</span>
)}
</button>
{open && (
<div className="absolute left-0 mt-1 z-20 min-w-[12rem] rounded-xl bg-surface border border-border shadow-lg p-1.5">
<div className="flex flex-col">
<div className="py-1">
{DB_TYPES.map((dbType) => {
const checked = activeDbTypes.includes(dbType.value);
return (
<label
key={dbType.value}
className={`flex items-center gap-2 px-2 py-1.5 rounded-lg text-xs cursor-pointer transition-colors ${
checked ? "bg-accent/10" : "hover:bg-surface-raised"
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleDbType(dbType.value)}
className="w-4 h-4 rounded border-border accent-accent cursor-pointer"
/>
<span className={checked ? "text-accent" : "text-text"}>
{dbType.label}
</span>
</label>
);
})}
</div>
<div className="px-2 py-2">
<label className="block text-[10px] uppercase tracking-wider text-text-muted mb-1">
Environment
</label>
<SelectDropdown
value={activeEnvironment ?? ""}
onChange={(val) => setEnvironment(val === "" ? null : val)}
options={[
{ value: "", label: "All" },
{ value: "production", label: "Production" },
{ value: "staging", label: "Staging" },
{ value: "development", label: "Development" },
{ value: "none", label: "None" },
]}
placeholder="All"
aria-label="Environment filter"
/>
</div>
<div className="border-t border-border my-1" />
<button
onClick={handleClearAll}
className="w-full text-left px-2 py-1.5 text-xs text-text-muted hover:text-text hover:bg-surface-raised rounded-lg transition-colors cursor-pointer"
>
Clear all
</button>
</div>
</div>
)}
</div>
);
}
+7 -1
View File
@@ -1,4 +1,4 @@
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
import { Search, Command } from "lucide-react";
import { Input } from "../ui/Input";
import { useUiStore } from "../../stores/uiStore";
@@ -15,12 +15,18 @@ interface SearchBarProps {
export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(function SearchBar({ onDetectUrl }, ref) {
const [value, setValue] = useState("");
const setSearchQuery = useUiStore((s) => s.setSearchQuery);
const searchQuery = useUiStore((s) => s.searchQuery);
const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
}));
// Keep local input in sync with store (e.g. when Clear button resets it)
useEffect(() => {
setValue(searchQuery);
}, [searchQuery]);
return (
<div className="flex items-center justify-center w-full">
<div className="relative w-full max-w-xl">
@@ -0,0 +1,23 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { SearchableTagPicker } from "./SearchableTagPicker";
vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() }));
describe("SearchableTagPicker", () => {
it("shows 'No tags yet' when tags array is empty", () => {
render(<SearchableTagPicker tags={[]} selectedTagIds={[]} onToggle={() => {}} />);
expect(screen.getByText("No tags yet.")).toBeInTheDocument();
});
it("shows 'Create first tag' button when empty", () => {
render(<SearchableTagPicker tags={[]} selectedTagIds={[]} onToggle={() => {}} />);
expect(screen.getByText("Create first tag")).toBeInTheDocument();
});
it("shows inline creation form when 'Create first tag' is clicked", () => {
render(<SearchableTagPicker tags={[]} selectedTagIds={[]} onToggle={() => {}} />);
fireEvent.click(screen.getByText("Create first tag"));
expect(screen.getByPlaceholderText("Tag name")).toBeInTheDocument();
});
});
+75 -1
View File
@@ -1,6 +1,7 @@
import { useState } from "react";
import type { Tag } from "../../lib/types";
import { Search } from "lucide-react";
import { useConnectionStore } from "../../stores/connectionStore";
interface SearchableTagPickerProps {
tags: Tag[];
@@ -28,7 +29,16 @@ export function SearchableTagPicker({ tags, selectedTagIds, onToggle }: Searchab
</div>
<div className="max-h-32 overflow-y-auto space-y-1">
{filtered.length === 0 && (
<div className="text-xs text-text-muted py-1">No tags found</div>
<div className="py-2 text-xs text-text-muted">
{tags.length === 0 ? (
<>
<p>No tags yet.</p>
<InlineTagCreator onCreated={onToggle} />
</>
) : (
<p>No tags match your search.</p>
)}
</div>
)}
{filtered.map((tag) => {
const active = selectedTagIds.includes(tag.id);
@@ -54,3 +64,67 @@ export function SearchableTagPicker({ tags, selectedTagIds, onToggle }: Searchab
</div>
);
}
function InlineTagCreator({ onCreated }: { onCreated: (tagId: string) => void }) {
const [name, setName] = useState("");
const [color, setColor] = useState("#8b5cf6");
const [show, setShow] = useState(false);
if (!show) {
return (
<button onClick={() => setShow(true)} className="text-accent hover:underline cursor-pointer text-xs">
Create first tag
</button>
);
}
const handleCreate = async () => {
if (!name.trim()) return;
try {
await useConnectionStore.getState().createTag({ name: name.trim(), color });
const tags = useConnectionStore.getState().tags;
const created = tags.find((t) => t.name === name.trim());
if (created) onCreated(created.id);
setName("");
setShow(false);
} catch {
// silent
}
};
const COLORS = ["#ef4444", "#f97316", "#eab308", "#22c55e", "#06b6d4", "#3b82f6", "#8b5cf6", "#d946ef", "#ec4899", "#6b7280"];
return (
<div className="space-y-2 p-2 border border-border rounded-lg bg-canvas">
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Tag name"
className="w-full rounded-full bg-surface border border-border px-3 py-1.5 text-xs text-text placeholder-text-muted/60 focus:outline-none focus:border-accent transition-colors"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") handleCreate();
if (e.key === "Escape") setShow(false);
}}
/>
<div className="flex gap-1 flex-wrap">
{COLORS.map((c) => (
<button
key={c}
onClick={() => setColor(c)}
className={`w-5 h-5 rounded-full border-2 transition-colors cursor-pointer ${color === c ? "border-text" : "border-transparent"}`}
style={{ backgroundColor: c }}
/>
))}
</div>
<div className="flex gap-2">
<button onClick={handleCreate} className="text-xs text-white bg-accent rounded-full px-3 py-1 cursor-pointer hover:bg-accent-hover transition-colors">
Create
</button>
<button onClick={() => setShow(false)} className="text-xs text-text-muted cursor-pointer hover:text-text">
Cancel
</button>
</div>
</div>
);
}
@@ -0,0 +1,67 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { TagFilterDropdown } from "./TagFilterDropdown";
import { useConnectionStore } from "../../stores/connectionStore";
import { useUiStore } from "../../stores/uiStore";
import type { Tag } from "../../lib/types";
vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() }));
const makeTag = (overrides: Partial<Tag> = {}): Tag => ({
id: "t1", name: "production", color: "#ef4444", created_at: "", ...overrides,
});
beforeEach(() => {
useConnectionStore.setState({
connections: [], folders: [], tags: [
makeTag({ id: "t1", name: "production", color: "#ef4444" }),
makeTag({ id: "t2", name: "staging", color: "#f59e0b" }),
],
tagOrder: [], loading: false, error: null,
});
useUiStore.setState({ activeTagIds: [], activeView: "home" });
});
describe("TagFilterDropdown", () => {
it("renders a button with Tag icon", () => {
render(<TagFilterDropdown />);
expect(screen.getByText("Tags")).toBeInTheDocument();
});
it("opens dropdown on button click", () => {
render(<TagFilterDropdown />);
fireEvent.click(screen.getByText("Tags"));
expect(screen.getByText("production")).toBeInTheDocument();
expect(screen.getByText("staging")).toBeInTheDocument();
});
it("shows checkboxes for each tag", () => {
render(<TagFilterDropdown />);
fireEvent.click(screen.getByText("Tags"));
expect(screen.getAllByRole("checkbox")).toHaveLength(2);
});
it("toggles tag filter when checkbox is clicked", () => {
render(<TagFilterDropdown />);
fireEvent.click(screen.getByText("Tags"));
fireEvent.click(screen.getAllByRole("checkbox")[0]);
expect(useUiStore.getState().activeTagIds).toContain("t1");
fireEvent.click(screen.getAllByRole("checkbox")[0]);
expect(useUiStore.getState().activeTagIds).not.toContain("t1");
});
it("shows 'Manage tags' link that navigates to settings", () => {
render(<TagFilterDropdown />);
fireEvent.click(screen.getByText("Tags"));
fireEvent.click(screen.getByText("Manage tags"));
expect(useUiStore.getState().activeView).toBe("settings");
});
it("shows empty state when no tags exist", () => {
useConnectionStore.setState({ tags: [] });
render(<TagFilterDropdown />);
fireEvent.click(screen.getByText("Tags"));
expect(screen.getByText("No tags yet")).toBeInTheDocument();
expect(screen.getByText("Create in Settings")).toBeInTheDocument();
});
});
+106
View File
@@ -0,0 +1,106 @@
import { useEffect, useRef, useState } from "react";
import { Tag as TagIcon } from "lucide-react";
import { useSortedTags } from "../../hooks/useSortedTags";
import { useUiStore } from "../../stores/uiStore";
export function TagFilterDropdown() {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const tags = useSortedTags();
const activeTagIds = useUiStore((s) => s.activeTagIds);
const toggleTag = useUiStore((s) => s.toggleTag);
const setActiveView = useUiStore((s) => s.setActiveView);
const activeCount = activeTagIds.length;
const hasActiveFilters = activeCount > 0;
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open]);
const handleManageTags = () => {
setActiveView("settings");
setOpen(false);
};
return (
<div className="relative inline-block" ref={containerRef}>
<button
onClick={() => setOpen((o) => !o)}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium border transition-all cursor-pointer ${
hasActiveFilters
? "bg-accent/10 border-accent text-accent"
: "bg-transparent border-border text-text-muted hover:text-text hover:border-border-hover"
}`}
>
<TagIcon size={14} />
<span>Tags</span>
{hasActiveFilters && (
<span className="ml-0.5 flex items-center justify-center min-w-[1.125rem] h-[1.125rem] px-1 rounded-full text-[10px] bg-accent text-white">
{activeCount}
</span>
)}
</button>
{open && (
<div className="absolute left-0 mt-1 z-20 min-w-[12rem] rounded-xl bg-surface border border-border shadow-lg p-1.5">
{tags.length === 0 ? (
<div className="p-3 text-center">
<div className="text-xs text-text-muted mb-2">No tags yet</div>
<button
onClick={handleManageTags}
className="text-xs text-accent hover:underline cursor-pointer"
>
Create in Settings
</button>
</div>
) : (
<div className="flex flex-col">
<div className="max-h-48 overflow-y-auto py-1">
{tags.map((tag) => {
const checked = activeTagIds.includes(tag.id);
return (
<label
key={tag.id}
className={`flex items-center gap-2 px-2 py-1.5 rounded-lg text-xs cursor-pointer transition-colors ${
checked ? "bg-accent/10" : "hover:bg-surface-raised"
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleTag(tag.id)}
className="w-4 h-4 rounded border-border accent-accent cursor-pointer"
/>
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: tag.color }}
/>
<span className={checked ? "text-accent" : "text-text"}>
{tag.name}
</span>
</label>
);
})}
</div>
<div className="border-t border-border my-1" />
<button
onClick={handleManageTags}
className="w-full text-left px-2 py-1.5 text-xs text-text-muted hover:text-text hover:bg-surface-raised rounded-lg transition-colors cursor-pointer"
>
Manage tags
</button>
</div>
)}
</div>
)}
</div>
);
}
+138
View File
@@ -0,0 +1,138 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { renderHook } from "@testing-library/react";
import { useFilteredConnections } from "./useConnections";
import { useConnectionStore } from "../stores/connectionStore";
import { useUiStore } from "../stores/uiStore";
import type { Connection } from "../lib/types";
vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() }));
const makeConn = (overrides: Partial<Connection> = {}): Connection => ({
id: "c1",
name: "Test DB",
db_type: "postgresql",
host: "localhost",
port: 5432,
username: null,
folder_id: null,
keychain_ref: null,
tag_ids: [],
created_at: "",
updated_at: "",
database: null,
environment: null,
ssh_host: null,
ssh_port: null,
ssh_user: null,
ssh_auth_method: null,
ssh_private_key_path: null,
ssl_mode: null,
ssl_ca_path: null,
ssl_cert_path: null,
ssl_key_path: null,
...overrides,
});
beforeEach(() => {
useConnectionStore.setState({
connections: [
makeConn({ id: "c1", name: "Root DB", folder_id: null }),
makeConn({ id: "c2", name: "Nested DB", folder_id: "f1" }),
makeConn({ id: "c3", name: "Deep DB", folder_id: "f2" }),
],
folders: [
{ id: "f1", name: "F1", parent_id: null, tag_ids: [], created_at: "", updated_at: "" },
{ id: "f2", name: "F2", parent_id: "f1", tag_ids: [], created_at: "", updated_at: "" },
],
tags: [],
tagOrder: [],
loading: false,
error: null,
});
useUiStore.setState({
searchQuery: "",
activeFolderId: null,
activeTagIds: [],
activeDbTypes: [],
activeEnvironment: null,
});
});
describe("useFilteredConnections", () => {
it("returns all connections at root with no filters", () => {
const { result } = renderHook(() => useFilteredConnections());
expect(result.current).toHaveLength(3);
});
it("returns folder-descendant connections when browsing a folder", () => {
useUiStore.setState({ activeFolderId: "f1" });
const { result } = renderHook(() => useFilteredConnections());
expect(result.current).toHaveLength(2);
expect(result.current.map((c) => c.id)).toEqual(["c2", "c3"]);
});
it("returns ALL connections matching search regardless of active folder", () => {
// c1 "Root DB" is at root (folder_id: null) — filtered out by
// current folder-scope code when activeFolderId is set.
useUiStore.setState({ searchQuery: "Root", activeFolderId: "f1" });
const { result } = renderHook(() => useFilteredConnections());
expect(result.current).toHaveLength(1);
expect(result.current[0].id).toBe("c1");
});
it("returns ALL connections matching tag filter regardless of folder", () => {
useConnectionStore.setState({
connections: [
makeConn({ id: "c1", name: "Root DB", folder_id: null, tag_ids: ["t1"] }),
makeConn({ id: "c2", name: "Nested DB", folder_id: "f1", tag_ids: [] }),
makeConn({ id: "c3", name: "Deep DB", folder_id: "f2", tag_ids: [] }),
],
tags: [{ id: "t1", name: "prod", color: "#f00", created_at: "" }],
});
useUiStore.setState({ activeTagIds: ["t1"], activeFolderId: "f1" });
const { result } = renderHook(() => useFilteredConnections());
expect(result.current).toHaveLength(1);
expect(result.current[0].id).toBe("c1");
});
it("returns ALL connections matching environment regardless of folder", () => {
useConnectionStore.setState({
connections: [
makeConn({ id: "c1", name: "Prod", db_type: "postgresql", folder_id: null, environment: "production" }),
makeConn({ id: "c2", name: "Dev", db_type: "sqlite", folder_id: "f1", environment: "development" }),
],
folders: [{ id: "f1", name: "F1", parent_id: null, tag_ids: [], created_at: "", updated_at: "" }],
});
useUiStore.setState({ activeEnvironment: "development", activeFolderId: null });
const { result } = renderHook(() => useFilteredConnections());
expect(result.current).toHaveLength(1);
expect(result.current[0].id).toBe("c2");
});
it("environment filter counts as active filter (bypasses folder scope)", () => {
useConnectionStore.setState({
connections: [
makeConn({ id: "c1", name: "Prod", db_type: "postgresql", folder_id: null, environment: "production" }),
makeConn({ id: "c2", name: "Prod2", db_type: "postgresql", folder_id: "f1", environment: "production" }),
],
folders: [{ id: "f1", name: "F1", parent_id: null, tag_ids: [], created_at: "", updated_at: "" }],
});
useUiStore.setState({ activeEnvironment: "production", activeFolderId: "f1" });
const { result } = renderHook(() => useFilteredConnections());
expect(result.current).toHaveLength(2);
});
it("returns ALL connections matching DB type filter regardless of folder", () => {
useConnectionStore.setState({
connections: [
makeConn({ id: "c1", name: "PG", db_type: "postgresql", folder_id: null }),
makeConn({ id: "c2", name: "SQLite", db_type: "sqlite", folder_id: "f1" }),
makeConn({ id: "c3", name: "Deep DB", folder_id: "f2" }),
],
});
useUiStore.setState({ activeDbTypes: ["sqlite"], activeFolderId: "f1" });
const { result } = renderHook(() => useFilteredConnections());
expect(result.current).toHaveLength(1);
expect(result.current[0].id).toBe("c2");
});
});
+9 -1
View File
@@ -11,14 +11,22 @@ export function useFilteredConnections(): Connection[] {
const activeFolderId = useUiStore((s) => s.activeFolderId);
const activeTagIds = useUiStore((s) => s.activeTagIds);
const activeDbTypes = useUiStore((s) => s.activeDbTypes);
const activeEnvironment = useUiStore((s) => s.activeEnvironment);
const hasFilters =
searchQuery.length > 0 ||
activeTagIds.length > 0 ||
activeDbTypes.length > 0 ||
(activeEnvironment !== null && activeEnvironment !== undefined);
let filtered = filterConnections(connections, tags, {
query: searchQuery,
activeTagIds,
activeDbTypes,
activeEnvironment,
});
if (activeFolderId) {
if (!hasFilters && activeFolderId) {
const allowed = new Set(getDescendantFolderIds(folders, activeFolderId));
filtered = filtered.filter(
(c) => c.folder_id !== null && allowed.has(c.folder_id),
+153
View File
@@ -0,0 +1,153 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
import { useDbConnection } from "./useDbConnection";
import { useDbViewerStore } from "../stores/dbViewerStore";
import * as commands from "../lib/commands";
vi.mock("../lib/commands", () => ({
dbConnect: vi.fn().mockResolvedValue(undefined),
dbDisconnect: vi.fn().mockResolvedValue(undefined),
getDatabases: vi.fn().mockResolvedValue([]),
getSchemas: vi.fn().mockResolvedValue([]),
getTables: vi.fn().mockResolvedValue([]),
getConnectionPassword: vi.fn().mockResolvedValue("pw"),
}));
const mockCommands = vi.mocked(commands);
const mockConnection = {
id: "c1",
name: "Conn",
db_type: "postgresql",
host: "localhost",
port: 5432,
username: "postgres",
database: "mydb",
folder_id: null,
keychain_ref: null,
tag_ids: [],
environment: null,
ssh_host: null,
ssh_port: null,
ssh_user: null,
ssh_auth_method: null,
ssh_private_key_path: null,
ssl_mode: null,
ssl_ca_path: null,
ssl_cert_path: null,
ssl_key_path: null,
created_at: "2026-07-26T00:00:00Z",
updated_at: "2026-07-26T00:00:00Z",
};
vi.mock("../stores/connectionStore", () => ({
useConnectionStore: {
getState: () => ({
connections: [mockConnection],
getConnectionPassword: async () => "pw",
}),
},
}));
function Harness() {
const { connect } = useDbConnection("c1");
return (
<button type="button" onClick={() => connect()}>
connect
</button>
);
}
describe("useDbConnection", () => {
beforeEach(() => {
vi.clearAllMocks();
useDbViewerStore.getState().reset();
mockCommands.getDatabases.mockResolvedValue(["mydb", "otherdb"]);
mockCommands.getSchemas.mockResolvedValue(["app", "public"]);
mockCommands.getTables.mockResolvedValue([]);
});
it("connects and smart-selects the public schema when available", async () => {
render(<Harness />);
fireEvent.click(screen.getByText("connect"));
await waitFor(() => {
expect(useDbViewerStore.getState().currentDatabase).toBe("mydb");
});
expect(useDbViewerStore.getState().currentSchema).toBe("public");
// tables are fetched for the smart default schema
expect(mockCommands.getTables).toHaveBeenCalledWith("c1", "public");
});
it("falls back to the first schema when public is absent", async () => {
mockCommands.getSchemas.mockResolvedValue(["analytics", "app"]);
render(<Harness />);
fireEvent.click(screen.getByText("connect"));
await waitFor(() => {
expect(useDbViewerStore.getState().currentSchema).toBe("analytics");
});
expect(mockCommands.getTables).toHaveBeenCalledWith("c1", "analytics");
});
it("reconnects and refreshes schemas when the database changes", async () => {
render(<Harness />);
fireEvent.click(screen.getByText("connect"));
await waitFor(() => {
expect(useDbViewerStore.getState().currentDatabase).toBe("mydb");
});
mockCommands.getDatabases.mockResolvedValue(["mydb", "otherdb"]);
mockCommands.getSchemas.mockResolvedValue(["analytics", "public"]);
await act(async () => {
useDbViewerStore.setState({ currentDatabase: "otherdb" });
});
expect(mockCommands.dbConnect).toHaveBeenLastCalledWith(
"c1",
expect.objectContaining({ database: "otherdb" }),
);
expect(useDbViewerStore.getState().schemas).toEqual([
"analytics",
"public",
]);
expect(useDbViewerStore.getState().currentSchema).toBe("public");
// tables refetched for the new database's smart schema
expect(mockCommands.getTables).toHaveBeenLastCalledWith("c1", "public");
});
it("refreshes schemas when switching back to the initial database", async () => {
render(<Harness />);
fireEvent.click(screen.getByText("connect"));
await waitFor(() => {
expect(useDbViewerStore.getState().currentDatabase).toBe("mydb");
});
// Switch away to otherdb
await act(async () => {
useDbViewerStore.setState({ currentDatabase: "otherdb" });
});
await waitFor(() =>
expect(mockCommands.dbConnect).toHaveBeenLastCalledWith(
"c1",
expect.objectContaining({ database: "otherdb" }),
),
);
// Switch back to mydb — must reconnect and refresh schemas again
mockCommands.getSchemas.mockResolvedValue(["public", "reporting"]);
await act(async () => {
useDbViewerStore.setState({ currentDatabase: "mydb" });
});
expect(mockCommands.dbConnect).toHaveBeenLastCalledWith(
"c1",
expect.objectContaining({ database: "mydb" }),
);
expect(useDbViewerStore.getState().schemas).toEqual([
"public",
"reporting",
]);
expect(useDbViewerStore.getState().currentSchema).toBe("public");
});
});
+50 -20
View File
@@ -3,7 +3,8 @@ import { useConnectionStore } from "../stores/connectionStore";
import { useDbViewerStore } from "../stores/dbViewerStore";
import { useNotificationStore } from "../stores/notificationStore";
import * as cmd from "../lib/commands";
import type { ConnectionInput } from "../lib/types";
import { pickDefaultSchema } from "../lib/utils";
import type { ConnectionInput, TableInfo } from "../lib/types";
export function useDbConnection(connectionId: string) {
const reset = useDbViewerStore((s) => s.reset);
@@ -14,7 +15,10 @@ export function useDbConnection(connectionId: string) {
const notify = useNotificationStore((s) => s.notify);
const [connectionError, setConnectionError] = useState<string | null>(null);
const inputRef = useRef<ConnectionInput | null>(null);
const initialDbRef = useRef<string | null>(null);
// The database the pool is currently connected to. Unlike the selected
// `currentDatabase`, this lets us reconnect whenever the selection drifts
// from the live connection (including switching back to the first DB).
const connectedDbRef = useRef<string | null>(null);
const connect = useCallback(async () => {
const conn = useConnectionStore
@@ -56,21 +60,34 @@ export function useDbConnection(connectionId: string) {
await cmd.dbConnect(connectionId, input);
setConnectionError(null);
inputRef.current = input;
connectedDbRef.current =
input.db_type === "sqlite"
? "main"
: (input.database ?? "postgres");
// Load initial data
// Load initial data, smart-selecting the default schema (e.g. `public`)
const databases = await cmd
.getDatabases(connectionId)
.catch(() => [] as string[]);
const schemas = await cmd
.getSchemas(connectionId)
.catch(() => [] as string[]);
const tables = await cmd.getTables(connectionId);
const defaultSchema = pickDefaultSchema(schemas);
const tables = await cmd.getTables(
connectionId,
defaultSchema ?? undefined,
);
populate(databases, schemas, tables);
if (databases.length > 0) {
setCurrentDatabase(databases[0]);
initialDbRef.current = databases[0];
// Prefer the connection's configured database, fall back to the first
// available one so the dropdown matches what the pool is connected to.
const preferred =
input.database && databases.includes(input.database)
? input.database
: databases[0];
setCurrentDatabase(preferred);
}
if (schemas.length > 0) setCurrentSchema(schemas[0]);
setCurrentSchema(defaultSchema);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
setConnectionError(msg);
@@ -89,29 +106,42 @@ export function useDbConnection(connectionId: string) {
};
}, [connectionId, connect, reset]);
// Database switch effect: reconnect when user changes database from dropdown
// Database switch effect: whenever the selected database drifts from the
// pool's live connection, reconnect and refresh schemas/tables for that DB.
useEffect(() => {
if (!currentDatabase || !inputRef.current) return;
if (currentDatabase === initialDbRef.current) return;
if (currentDatabase === connectedDbRef.current) return;
let cancelled = false;
const reconnect = async () => {
const input = { ...inputRef.current!, database: currentDatabase };
try {
await cmd.dbConnect(connectionId, input);
const schemas = await cmd.getSchemas(connectionId);
const tables = await cmd.getTables(connectionId);
populate(
useDbViewerStore.getState().databases,
schemas,
tables,
);
if (schemas.length > 0) setCurrentSchema(schemas[0]);
} catch {
/* silent */
if (cancelled) return;
connectedDbRef.current = currentDatabase;
const schemas = await cmd
.getSchemas(connectionId)
.catch(() => [] as string[]);
if (cancelled) return;
const newSchema = pickDefaultSchema(schemas);
const tables = await cmd
.getTables(connectionId, newSchema ?? undefined)
.catch(() => [] as TableInfo[]);
if (cancelled) return;
populate(useDbViewerStore.getState().databases, schemas, tables);
setCurrentSchema(newSchema);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
// Revert the selection so the dropdown matches the live connection
setCurrentDatabase(connectedDbRef.current);
notify(`Failed to switch database: ${msg}`, "error");
}
};
reconnect();
}, [currentDatabase, connectionId, populate, setCurrentSchema]);
return () => {
cancelled = true;
};
}, [currentDatabase, connectionId, populate, setCurrentSchema, notify]);
return { connectionError, connect };
}
+7
View File
@@ -17,6 +17,13 @@
--color-accent-muted: #60A5FA;
--font-family-heading: "Space Mono", monospace;
--font-family-sans: "Outfit", sans-serif;
--animate-toolbar-pulse: toolbar-pulse 1.6s ease-in-out infinite;
@keyframes toolbar-pulse {
0%, 100% { opacity: 0; }
50% { opacity: 0.12; }
}
}
:root {
+35
View File
@@ -4,6 +4,7 @@ vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn().mockResolvedValue({ tables: [], relationships: [] }),
}));
import { invoke } from "@tauri-apps/api/core";
import {
testConnection,
dbConnect,
@@ -15,6 +16,9 @@ import {
executeChange,
refreshConnection,
getSchemaGraph,
executeQuery,
getQueryHistory,
clearQueryHistory,
} from "./commands";
import type { SchemaGraph } from "./types";
@@ -68,3 +72,34 @@ describe("commands", () => {
});
});
});
describe("query commands", () => {
it("executeQuery calls invoke with correct args", async () => {
const mockResult = { columns: [], rows: [], total_rows: 0, page: 1, page_size: 50 };
vi.mocked(invoke).mockResolvedValueOnce(mockResult);
const result = await executeQuery("conn-1", "SELECT 1", 1, 50);
expect(invoke).toHaveBeenCalledWith("execute_query", {
connectionId: "conn-1",
query: "SELECT 1",
page: 1,
pageSize: 50,
});
expect(result).toEqual(mockResult);
});
it("getQueryHistory calls invoke", async () => {
const mockHistory = [{ id: "h1", connection_id: "c1", query_text: "SELECT 1", status: "success", executed_at: "2025-01-01" }];
vi.mocked(invoke).mockResolvedValueOnce(mockHistory);
const result = await getQueryHistory("conn-1", 10, 0);
expect(invoke).toHaveBeenCalledWith("get_query_history", { connectionId: "conn-1", limit: 10, offset: 0 });
expect(result).toEqual(mockHistory);
});
it("clearQueryHistory calls invoke", async () => {
vi.mocked(invoke).mockResolvedValueOnce(undefined);
await clearQueryHistory("conn-1");
expect(invoke).toHaveBeenCalledWith("clear_query_history", { connectionId: "conn-1" });
});
});
+34
View File
@@ -147,3 +147,37 @@ export async function getSchemaGraph(
): Promise<SchemaGraph> {
return invoke<SchemaGraph>("get_schema_graph", { connectionId, schema });
}
// ─── Query History ──────────────────────────────────────────────
export interface QueryHistoryEntry {
id: string;
connection_id: string;
query_text: string;
execution_time_ms: number | null;
row_count: number | null;
status: string;
error_message: string | null;
executed_at: string;
}
export async function executeQuery(
connectionId: string,
query: string,
page: number,
pageSize: number,
): Promise<QueryResult> {
return invoke<QueryResult>("execute_query", { connectionId, query, page, pageSize });
}
export async function getQueryHistory(
connectionId: string,
limit: number,
offset: number,
): Promise<QueryHistoryEntry[]> {
return invoke<QueryHistoryEntry[]>("get_query_history", { connectionId, limit, offset });
}
export async function clearQueryHistory(connectionId: string): Promise<void> {
return invoke<void>("clear_query_history", { connectionId });
}
+80
View File
@@ -0,0 +1,80 @@
/**
* Local Monaco setup bundles monaco-editor with the app so no CDN is
* needed and the editor works fully offline.
*
* Must be imported once, before any @monaco-editor/react <Editor /> mounts.
*/
import * as monaco from "monaco-editor";
import { loader } from "@monaco-editor/react";
import EditorWorker from "monaco-editor/editor/editor.worker?worker";
import { useDbViewerStore } from "../stores/dbViewerStore";
import { useUiStore } from "../stores/uiStore";
import {
buildSqlSuggestions,
buildColumnSuggestions,
getColumnsForTable,
getCachedColumns,
parseTableRef,
} from "./sqlCompletion";
// Use the bundled editor worker (SQL has no dedicated language worker)
self.MonacoEnvironment = {
getWorker: () => new EditorWorker(),
};
// Hand the already-imported monaco instance to @monaco-editor/react so it
// skips its CDN download entirely.
loader.config({ monaco });
// SQL autocomplete:
// - after `table.` (or `schema.table.`): suggest that table's columns
// (fetched lazily via schema introspection and cached per schema)
// - otherwise: keywords + table names from the active schema
monaco.languages.registerCompletionItemProvider("sql", {
provideCompletionItems: (
model,
position,
): monaco.languages.CompletionList | Promise<monaco.languages.CompletionList> => {
const { tables, currentSchema } = useDbViewerStore.getState();
const line = model.getLineContent(position.lineNumber);
const before = line.slice(0, position.column - 1);
const word = model.getWordUntilPosition(position);
const range = new monaco.Range(
position.lineNumber,
word.startColumn,
position.lineNumber,
word.endColumn,
);
const withRange = (s: { label: string; insertText: string; kind: string }) => ({
label: s.label,
insertText: s.insertText,
kind:
s.kind === "keyword"
? monaco.languages.CompletionItemKind.Keyword
: s.kind === "table"
? monaco.languages.CompletionItemKind.Struct
: monaco.languages.CompletionItemKind.Field,
range,
});
const tableRef = parseTableRef(before);
if (tableRef) {
const schema = tableRef.schema ?? currentSchema;
const connectionId = useUiStore.getState().activeConnectionId;
const cached = schema ? getCachedColumns(schema, tableRef.table) : undefined;
if (cached) {
return {
suggestions: buildColumnSuggestions(cached).map(withRange),
};
}
// Not introspected yet: warm the cache in the background and ask Monaco
// to re-request once the columns are available.
void getColumnsForTable(connectionId, schema, tableRef.table);
return { suggestions: [], incomplete: true };
}
return {
suggestions: buildSqlSuggestions(tables, currentSchema).map(withRange),
};
},
});
+152
View File
@@ -0,0 +1,152 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
buildSqlSuggestions,
SQL_KEYWORDS,
parseTableRef,
buildColumnSuggestions,
getColumnsForTable,
getCachedColumns,
} from "./sqlCompletion";
import type { TableInfo } from "./types";
import { getSchemaGraph } from "./commands";
vi.mock("./commands", () => ({
getSchemaGraph: vi.fn(),
}));
const mockGetSchemaGraph = vi.mocked(getSchemaGraph);
const tables: TableInfo[] = [
{ name: "users", schema: "public", table_type: "TABLE" },
{ name: "orders", schema: "public", table_type: "TABLE" },
{ name: "audit_log", schema: "audit", table_type: "TABLE" },
];
describe("parseTableRef", () => {
it("parses a bare table before the cursor dot", () => {
expect(parseTableRef("SELECT * FROM users.")).toEqual({
schema: null,
table: "users",
});
});
it("parses a schema-qualified table", () => {
expect(parseTableRef("SELECT * FROM public.users.")).toEqual({
schema: "public",
table: "users",
});
});
it("returns null when there is no trailing dot", () => {
expect(parseTableRef("SELECT * FROM users WHERE id")).toBeNull();
expect(parseTableRef("SELECT")).toBeNull();
expect(parseTableRef("")).toBeNull();
});
});
describe("buildColumnSuggestions", () => {
it("maps columns to column-kind suggestions", () => {
const suggestions = buildColumnSuggestions([
{ name: "id" },
{ name: "email" },
]);
expect(suggestions).toEqual([
{ label: "id", insertText: "id", kind: "column" },
{ label: "email", insertText: "email", kind: "column" },
]);
});
});
describe("getColumnsForTable", () => {
beforeEach(() => {
mockGetSchemaGraph.mockReset();
});
it("fetches columns from the schema graph and caches them", async () => {
mockGetSchemaGraph.mockResolvedValue({
tables: [
{
name: "users",
schema: "public",
table_type: "TABLE",
columns: [{ name: "id" } as never],
},
],
relationships: [],
} as never);
const cols = await getColumnsForTable("c1", "public", "users");
expect(cols.map((c) => c.name)).toEqual(["id"]);
expect(mockGetSchemaGraph).toHaveBeenCalledTimes(1);
// cached: a second lookup does not refetch
await getColumnsForTable("c1", "public", "users");
expect(mockGetSchemaGraph).toHaveBeenCalledTimes(1);
});
it("returns an empty list when the fetch fails", async () => {
mockGetSchemaGraph.mockRejectedValue(new Error("boom"));
const cols = await getColumnsForTable("c1", "public", "orders");
expect(cols).toEqual([]);
});
it("exposes cached columns synchronously", async () => {
mockGetSchemaGraph.mockResolvedValue({
tables: [
{
name: "users",
schema: "public",
table_type: "TABLE",
columns: [{ name: "id" } as never],
},
],
relationships: [],
} as never);
await getColumnsForTable("c1", "public", "users");
expect(getCachedColumns("public", "users")?.map((c) => c.name)).toEqual([
"id",
]);
expect(getCachedColumns("public", "missing")).toBeUndefined();
});
});
describe("buildSqlSuggestions", () => {
it("includes core SQL keywords", () => {
const suggestions = buildSqlSuggestions([], null);
const labels = suggestions.map((s) => s.label);
for (const kw of ["SELECT", "FROM", "WHERE", "JOIN", "INSERT"]) {
expect(labels).toContain(kw);
}
});
it("labels keywords as keyword kind", () => {
const suggestions = buildSqlSuggestions([], null);
const select = suggestions.find((s) => s.label === "SELECT");
expect(select?.kind).toBe("keyword");
expect(select?.insertText).toBe("SELECT");
});
it("includes table names from the current schema", () => {
const suggestions = buildSqlSuggestions(tables, "public");
const labels = suggestions.map((s) => s.label);
expect(labels).toContain("users");
expect(labels).toContain("orders");
expect(labels).not.toContain("audit_log");
});
it("includes all tables when no schema is selected", () => {
const suggestions = buildSqlSuggestions(tables, null);
const labels = suggestions.map((s) => s.label);
expect(labels).toContain("audit_log");
});
it("labels tables as table kind", () => {
const suggestions = buildSqlSuggestions(tables, "public");
const users = suggestions.find((s) => s.label === "users");
expect(users?.kind).toBe("table");
});
it("exports a non-empty keyword list", () => {
expect(SQL_KEYWORDS.length).toBeGreaterThan(20);
});
});
+172
View File
@@ -0,0 +1,172 @@
import type { TableInfo, GraphColumn } from "./types";
import { getSchemaGraph } from "./commands";
export const SQL_KEYWORDS = [
"SELECT",
"FROM",
"WHERE",
"INSERT",
"INTO",
"VALUES",
"UPDATE",
"SET",
"DELETE",
"CREATE",
"TABLE",
"DROP",
"ALTER",
"ADD",
"COLUMN",
"PRIMARY",
"KEY",
"FOREIGN",
"REFERENCES",
"INDEX",
"UNIQUE",
"JOIN",
"INNER",
"LEFT",
"RIGHT",
"OUTER",
"FULL",
"CROSS",
"ON",
"AS",
"AND",
"OR",
"NOT",
"NULL",
"IS",
"IN",
"EXISTS",
"BETWEEN",
"LIKE",
"ILIKE",
"LIMIT",
"OFFSET",
"ORDER",
"BY",
"GROUP",
"HAVING",
"DISTINCT",
"UNION",
"ALL",
"CASE",
"WHEN",
"THEN",
"ELSE",
"END",
"ASC",
"DESC",
"WITH",
"RETURNING",
"BEGIN",
"COMMIT",
"EXPLAIN",
"GRANT",
"REVOKE",
] as const;
export type SqlSuggestionKind = "keyword" | "table" | "column";
export interface SqlSuggestion {
label: string;
insertText: string;
kind: SqlSuggestionKind;
}
/**
* Parse the text before the cursor into a `table.` reference. Returns the
* table (and optional schema qualifier) when the text ends in a dot right
* after an identifier, e.g. `users.` or `public.users.`.
*/
export function parseTableRef(
text: string,
): { schema: string | null; table: string } | null {
const match = text.match(/(?:(\w+)\.)?(\w+)\.\s*$/);
if (!match) return null;
return { schema: match[1] ?? null, table: match[2] };
}
/**
* Build column suggestions (used after a `table.` reference).
*/
export function buildColumnSuggestions(
columns: { name: string }[],
): SqlSuggestion[] {
return columns.map((c) => ({
label: c.name,
insertText: c.name,
kind: "column" as const,
}));
}
// Cache of table columns, keyed by "schema.table".
const columnCache = new Map<string, GraphColumn[]>();
/**
* Synchronously read cached columns for a table, if the schema graph for its
* schema has already been fetched.
*/
export function getCachedColumns(
schema: string,
table: string,
): GraphColumn[] | undefined {
return columnCache.get(`${schema}.${table}`);
}
/**
* Fetch (and cache) the columns of a table via the schema-graph introspection
* command. Caches the whole schema in one round trip. Returns [] on error so
* autocomplete degrades gracefully.
*/
export async function getColumnsForTable(
connectionId: string | null,
schema: string | null,
table: string,
): Promise<GraphColumn[]> {
const resolvedSchema = schema ?? "public";
const key = `${resolvedSchema}.${table}`;
const cached = columnCache.get(key);
if (cached) return cached;
if (!connectionId) return [];
try {
const graph = await getSchemaGraph(connectionId, resolvedSchema);
for (const t of graph.tables) {
columnCache.set(`${resolvedSchema}.${t.name}`, t.columns);
}
} catch {
return [];
}
return columnCache.get(key) ?? [];
}
/**
* Build editor suggestions for the SQL language: reserved keywords plus the
* table names in the currently selected schema.
*/
export function buildSqlSuggestions(
tables: TableInfo[],
schema: string | null,
): SqlSuggestion[] {
const keywords: SqlSuggestion[] = SQL_KEYWORDS.map((kw) => ({
label: kw,
insertText: kw,
kind: "keyword",
}));
const tableNames = new Set(
tables
.filter((t) => !schema || t.schema === schema)
.map((t) => t.name),
);
const tableSuggestions: SqlSuggestion[] = [...tableNames].map((name) => ({
label: name,
insertText: name,
kind: "table",
}));
return [...keywords, ...tableSuggestions];
}
+112
View File
@@ -6,6 +6,8 @@ import {
filterConnections,
getDescendantFolderIds,
getFolderPathLabel,
isDestructiveQuery,
pickDefaultSchema,
} from "./utils";
import type { Connection, Folder, Tag } from "./types";
@@ -19,6 +21,7 @@ const makeConnection = (over: Partial<Connection> = {}): Connection => ({
folder_id: null,
keychain_ref: null,
tag_ids: [],
environment: null,
created_at: "2026-07-26T00:00:00Z",
updated_at: "2026-07-26T00:00:00Z",
...over,
@@ -283,6 +286,39 @@ describe("filterConnections", () => {
it("search matches tag name", () => {
expect(filterConnections(conns, tags, { query: "cache" })).toEqual([conns[1]]);
});
it("matches ANY selected tag (OR semantics)", () => {
// c1 has t1, c2 has t2. Selecting both t1+t2 should return BOTH connections.
const result = filterConnections(conns, tags, { query: "", activeTagIds: ["t1", "t2"] });
expect(result).toHaveLength(2);
});
it("matches when connection has only one of multiple selected tags", () => {
const c3 = makeConnection({ id: "c3", name: "Cache", host: "cache.local", db_type: "postgresql", tag_ids: ["t1"], folder_id: "f1", environment: "production" });
const result = filterConnections([...conns, c3], tags, { query: "", activeTagIds: ["t1", "t2"] });
// c3 only has t1 but should still show
expect(result.map((c) => c.id)).toContain("c3");
});
it("filters by environment", () => {
const c1 = makeConnection({ id: "c1", name: "Prod", db_type: "postgresql", tag_ids: [], environment: "production" });
const c2 = makeConnection({ id: "c2", name: "Dev", db_type: "postgresql", tag_ids: [], environment: "development" });
const result = filterConnections([c1, c2], tags, { query: "", activeEnvironment: "production" });
expect(result).toEqual([c1]);
});
it("activeEnvironment 'none' filters to connections without environment", () => {
const c1 = makeConnection({ id: "c1", name: "Prod", db_type: "postgresql", tag_ids: [], environment: "production" });
const c2 = makeConnection({ id: "c2", name: "NoEnv", db_type: "postgresql", tag_ids: [], environment: null });
const result = filterConnections([c1, c2], tags, { query: "", activeEnvironment: "none" });
expect(result).toEqual([c2]);
});
it("environment null/undefined means no filtering", () => {
const c1 = makeConnection({ id: "c1", name: "Prod", db_type: "postgresql", tag_ids: [], environment: "production" });
const result = filterConnections([c1], tags, { query: "" });
expect(result).toEqual([c1]);
});
});
describe("getFolderPathLabel", () => {
@@ -303,3 +339,79 @@ describe("getFolderPathLabel", () => {
expect(getFolderPathLabel(folders, "missing")).toBe("Root");
});
});
describe("isDestructiveQuery", () => {
it("returns true for INSERT", () => {
expect(isDestructiveQuery("INSERT INTO users VALUES (1)")).toBe(true);
});
it("returns true for UPDATE", () => {
expect(isDestructiveQuery("UPDATE users SET name = 'x'")).toBe(true);
});
it("returns true for DELETE", () => {
expect(isDestructiveQuery("DELETE FROM users")).toBe(true);
});
it("returns true for DROP", () => {
expect(isDestructiveQuery("DROP TABLE users")).toBe(true);
});
it("returns true for ALTER", () => {
expect(isDestructiveQuery("ALTER TABLE users ADD COLUMN age int")).toBe(true);
});
it("returns true for TRUNCATE", () => {
expect(isDestructiveQuery("TRUNCATE TABLE users")).toBe(true);
});
it("returns true for CREATE", () => {
expect(isDestructiveQuery("CREATE TABLE t (id int)")).toBe(true);
});
it("returns true for REPLACE", () => {
expect(isDestructiveQuery("REPLACE INTO users VALUES (1)")).toBe(true);
});
it("returns false for SELECT", () => {
expect(isDestructiveQuery("SELECT * FROM users")).toBe(false);
});
it("returns false for EXPLAIN", () => {
expect(isDestructiveQuery("EXPLAIN SELECT * FROM users")).toBe(false);
});
it("returns false for WITH (CTE SELECT)", () => {
expect(isDestructiveQuery("WITH cte AS (SELECT 1) SELECT * FROM cte")).toBe(false);
});
it("returns false for SHOW", () => {
expect(isDestructiveQuery("SHOW search_path")).toBe(false);
});
it("strips line comments before checking", () => {
expect(isDestructiveQuery("-- harmless comment\nDROP TABLE users")).toBe(true);
});
it("strips block comments before checking", () => {
expect(isDestructiveQuery("/* harmless */ DROP TABLE users")).toBe(true);
});
it("returns false for empty string", () => {
expect(isDestructiveQuery("")).toBe(false);
});
it("returns false for whitespace only", () => {
expect(isDestructiveQuery(" \n\t ")).toBe(false);
});
it("is case-insensitive", () => {
expect(isDestructiveQuery("drop table users")).toBe(true);
expect(isDestructiveQuery("Drop Table users")).toBe(true);
});
});
describe("pickDefaultSchema", () => {
it("prefers the public schema when available", () => {
expect(pickDefaultSchema(["app", "public"])).toBe("public");
expect(pickDefaultSchema(["public"])).toBe("public");
});
it("prefers the main schema (SQLite) when available", () => {
expect(pickDefaultSchema(["main"])).toBe("main");
expect(pickDefaultSchema(["other", "main"])).toBe("main");
});
it("falls back to the first schema when no conventional one exists", () => {
expect(pickDefaultSchema(["analytics", "app"])).toBe("analytics");
expect(pickDefaultSchema(["zzz"])).toBe("zzz");
});
it("returns null for an empty list", () => {
expect(pickDefaultSchema([])).toBeNull();
});
});
+55 -2
View File
@@ -112,16 +112,29 @@ export function getChildFolders(folders: Folder[], parentId: string | null): Fol
export function filterConnections(
connections: Connection[],
tags: Tag[],
filter: { query: string; activeTagIds?: string[]; activeDbTypes?: DbType[] },
filter: {
query: string;
activeTagIds?: string[];
activeDbTypes?: DbType[];
activeEnvironment?: string | null;
},
): Connection[] {
const q = filter.query.trim().toLowerCase();
const tagIds = filter.activeTagIds ?? [];
const dbTypes = filter.activeDbTypes ?? [];
const activeEnvironment = filter.activeEnvironment;
const tagNameById = new Map(tags.map((t) => [t.id, t.name.toLowerCase()]));
return connections.filter((c) => {
if (dbTypes.length > 0 && !dbTypes.includes(c.db_type)) return false;
if (tagIds.length > 0 && !tagIds.every((id) => c.tag_ids.includes(id))) return false;
if (tagIds.length > 0 && !tagIds.some((id) => c.tag_ids.includes(id))) return false;
if (activeEnvironment !== undefined && activeEnvironment !== null && activeEnvironment !== "") {
if (activeEnvironment === "none") {
if (c.environment) return false;
} else if (c.environment !== activeEnvironment) {
return false;
}
}
if (q.length > 0) {
const tagNames = c.tag_ids.map((id) => tagNameById.get(id) ?? "").join(" ");
const haystack = `${c.name} ${c.host} ${c.db_type} ${tagNames}`.toLowerCase();
@@ -130,3 +143,43 @@ export function filterConnections(
return true;
});
}
const DESTRUCTIVE_KEYWORDS = new Set([
"INSERT", "UPDATE", "DELETE", "DROP", "ALTER",
"TRUNCATE", "CREATE", "REPLACE",
]);
/**
* Detect whether `sql` is a data-modifying statement by checking the
* first significant keyword after stripping comments and whitespace.
*
* This is a UX safety net, not a security boundary. The user is already
* authenticated to their own database the confirmation dialog prevents
* accidental data loss, not malicious access.
*/
export function isDestructiveQuery(sql: string): boolean {
// Strip block comments /* ... */
let stripped = sql.replace(/\/\*[\s\S]*?\*\//g, " ");
// Strip line comments -- ...
stripped = stripped.replace(/--[^\n]*/g, " ");
// Collapse whitespace
const tokens = stripped.trim().split(/\s+/);
if (tokens.length === 0 || tokens[0].length === 0) return false;
const first = tokens[0].toUpperCase();
return DESTRUCTIVE_KEYWORDS.has(first);
}
/**
* Pick the smart default schema for a freshly loaded database.
*
* Prefers conventional schemas (`public` for PostgreSQL, `main` for SQLite)
* and otherwise falls back to the first schema returned by the backend
* (which already excludes system schemas and is ordered alphabetically).
*/
export function pickDefaultSchema(schemas: string[]): string | null {
if (schemas.length === 0) return null;
for (const preferred of ["public", "main"]) {
if (schemas.includes(preferred)) return preferred;
}
return schemas[0];
}
+1
View File
@@ -1,6 +1,7 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./lib/monacoSetup";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
+41
View File
@@ -147,4 +147,45 @@ describe("moveConnection", () => {
await useConnectionStore.getState().moveConnection("c1", null); // c1 is already null
expect(spy).not.toHaveBeenCalled();
});
it("handles rapid successive drags without stale state", async () => {
const conn1 = makeConn({ id: "c1", folder_id: null });
const conn2 = makeConn({ id: "c2", folder_id: null, name: "Other" });
useConnectionStore.setState({
connections: [conn1, conn2],
folders: [{ id: "f1", name: "F1", parent_id: null, tag_ids: [], created_at: "", updated_at: "" }],
});
// First call hangs until we resolve it; second call resolves immediately
let resolveFirst: (v: Connection) => void;
const firstCall = new Promise<Connection>((r) => { resolveFirst = r; });
let callCount = 0;
vi.spyOn(commands, "updateConnection").mockImplementation(async (_id, input) => {
callCount++;
if (callCount === 1) {
return firstCall;
}
return { ...conn2, folder_id: (input as any).folder_id } as Connection;
});
// Start first drag (c1 → f1) — will be pending on updateConnection
const move1 = useConnectionStore.getState().moveConnection("c1", "f1");
// Immediately start second drag (c2 → f1) — should complete
await useConnectionStore.getState().moveConnection("c2", "f1");
// Second drag should have applied optimistically
expect(
useConnectionStore.getState().connections.find((c) => c.id === "c2")?.folder_id
).toBe("f1");
// Resolve the first drag's network call
resolveFirst!({ ...conn1, folder_id: "f1" } as Connection);
await move1;
// Both should be in f1 after both complete
const state = useConnectionStore.getState();
expect(state.connections.find((c) => c.id === "c1")?.folder_id).toBe("f1");
expect(state.connections.find((c) => c.id === "c2")?.folder_id).toBe("f1");
expect(callCount).toBe(2);
});
});
+9 -4
View File
@@ -123,14 +123,19 @@ export const useConnectionStore = create<ConnectionState>((set, get) => ({
if (!conn) return;
if (conn.folder_id === newFolderId) return;
const previousConnections = [...state.connections];
// Capture the snapshot atomically INSIDE the optimistic set()
// to avoid stale closure issues on rapid successive drags.
let previousState: { connections: Connection[] };
// Optimistic update
set((s) => ({
set((s) => {
previousState = { connections: [...s.connections] };
return {
connections: s.connections.map((c) =>
c.id === connectionId ? { ...c, folder_id: newFolderId } : c,
),
}));
};
});
try {
// Build a minimal ConnectionInput with only folder_id changed
@@ -156,7 +161,7 @@ export const useConnectionStore = create<ConnectionState>((set, get) => ({
};
await cmd.updateConnection(connectionId, input);
} catch (e) {
set({ connections: previousConnections });
set({ connections: previousState!.connections });
throw e;
}
},
+42
View File
@@ -180,3 +180,45 @@ describe("dbViewerStore", () => {
expect(state.tables).toEqual(tables);
});
});
describe("tabType discriminator", () => {
beforeEach(() => {
useDbViewerStore.getState().reset();
useDbViewerStore.setState({
schemas: ["public"],
currentSchema: "public",
currentDatabase: "mydb",
});
});
it("openQueryTab creates a query-type tab with empty query", () => {
useDbViewerStore.getState().openQueryTab();
const state = useDbViewerStore.getState();
expect(state.tabs).toHaveLength(1);
const tab = state.tabs[0];
expect(tab.tabType).toBe("query");
expect(tab.query).toBe("");
expect(tab.schema).toBe("public");
expect(tab.table).toBe("Query");
expect(state.activeTabId).toBe(tab.id);
});
it("creates sequential query tabs with unique IDs", () => {
const store = useDbViewerStore.getState();
store.openQueryTab();
store.openQueryTab();
store.openQueryTab();
const tabs = useDbViewerStore.getState().tabs;
expect(tabs).toHaveLength(3);
const ids = tabs.map((t) => t.id);
const uniqueIds = new Set(ids);
expect(uniqueIds.size).toBe(3);
tabs.forEach((t) => expect(t.tabType).toBe("query"));
});
it("openTab creates table-type tabs by default (backward compat)", () => {
useDbViewerStore.getState().openTab("public", "users");
const tab = useDbViewerStore.getState().tabs[0];
expect(tab.tabType).toBe("table");
});
});
+33
View File
@@ -49,6 +49,8 @@ export interface ViewerTab {
sortRules: SortRule[];
hiddenColumns: string[];
smartSortApplied: boolean;
tabType: "table" | "query";
query?: string;
}
// ─── Auto-increment counters ───────────────────────────────────
@@ -69,6 +71,8 @@ const initialTab = (schema: string, table: string, defaultPageSize?: number): Vi
sortRules: [],
hiddenColumns: [],
smartSortApplied: false,
tabType: "table",
query: undefined,
});
// ─── State interface ────────────────────────────────────────────
@@ -78,6 +82,7 @@ interface DbViewerState {
activeTabId: string | null;
defaultPageSize: number;
changesQueue: QueueItem[];
changesPanelExpanded: boolean;
databases: string[];
schemas: string[];
tables: TableInfo[];
@@ -91,6 +96,7 @@ interface DbViewerState {
// Actions
openTab: (schema: string, table: string, forceNew?: boolean) => void;
openQueryTab: () => void;
setDefaultPageSize: (size: number) => void;
closeTab: (tabId: string) => void;
setActiveTab: (tabId: string) => void;
@@ -119,6 +125,7 @@ interface DbViewerState {
cancelChange: (changeId: string) => void;
markChangeCommitted: (changeId: string) => void;
markChangeFailed: (changeId: string, error: string) => void;
toggleChangesPanel: () => void;
setCurrentDatabase: (db: string | null) => void;
setCurrentSchema: (schema: string | null) => void;
setFunctions: (functions: FunctionInfo[]) => void;
@@ -141,6 +148,7 @@ const initialState = {
activeTabId: null as string | null,
defaultPageSize: 50,
changesQueue: [] as QueueItem[],
changesPanelExpanded: true,
databases: [] as string[],
schemas: [] as string[],
tables: [] as TableInfo[],
@@ -176,6 +184,28 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
set({ tabs: [...tabs, tab], activeTabId: tab.id });
},
openQueryTab: () => {
const { tabs, currentSchema } = get();
const queryCount = tabs.filter((t) => t.tabType === "query").length;
const tab: ViewerTab = {
id: `tab-${++tabCounter}`,
schema: currentSchema ?? "public",
table: queryCount === 0 ? "Query" : `Query ${queryCount + 1}`,
page: 1,
pageSize: get().defaultPageSize,
loading: false,
error: null,
data: null,
filterRules: [],
sortRules: [],
hiddenColumns: [],
smartSortApplied: false,
tabType: "query",
query: "",
};
set({ tabs: [...tabs, tab], activeTabId: tab.id });
},
setDefaultPageSize: (size) => set({ defaultPageSize: size }),
closeTab: (tabId) => {
@@ -327,6 +357,9 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
),
})),
toggleChangesPanel: () =>
set((state) => ({ changesPanelExpanded: !state.changesPanelExpanded })),
setCurrentDatabase: (db) => set({ currentDatabase: db }),
setCurrentSchema: (schema) => set({ currentSchema: schema }),
setFunctions: (functions) => set({ functions }),
+12 -1
View File
@@ -1,7 +1,7 @@
import { describe, it, expect, beforeEach } from "vitest";
import { useUiStore } from "./uiStore";
beforeEach(() => useUiStore.setState({ searchQuery: "", activeFolderId: null, activeTagIds: [], activeDbTypes: [], activeView: "home", prefilledConnectionString: null, activeConnectionId: null }));
beforeEach(() => useUiStore.setState({ searchQuery: "", activeFolderId: null, activeTagIds: [], activeDbTypes: [], activeEnvironment: null, activeView: "home", prefilledConnectionString: null, activeConnectionId: null }));
describe("uiStore", () => {
it("starts on home view", () => expect(useUiStore.getState().activeView).toBe("home"));
@@ -41,6 +41,17 @@ describe("uiStore", () => {
expect(useUiStore.getState().prefilledConnectionString).toBeNull();
});
it("setEnvironment sets activeEnvironment", () => {
useUiStore.getState().setEnvironment("production");
expect(useUiStore.getState().activeEnvironment).toBe("production");
});
it("clearFilters resets activeEnvironment", () => {
useUiStore.getState().setEnvironment("staging");
useUiStore.getState().clearFilters();
expect(useUiStore.getState().activeEnvironment).toBeNull();
});
it("sets and clears activeConnectionId", () => {
useUiStore.getState().setActiveConnectionId("c1");
expect(useUiStore.getState().activeConnectionId).toBe("c1");
+5 -2
View File
@@ -6,6 +6,7 @@ interface UiState {
activeFolderId: string | null;
activeTagIds: string[];
activeDbTypes: DbType[];
activeEnvironment: string | null;
activeView: ActiveView;
selectedItemIds: string[];
prefilledConnectionString: string | null;
@@ -15,6 +16,7 @@ interface UiState {
setActiveFolderId: (id: string | null) => void;
toggleTag: (id: string) => void;
toggleDbType: (type: DbType) => void;
setEnvironment: (env: string | null) => void;
clearFilters: () => void;
toggleItemSelection: (id: string) => void;
selectAllItems: (ids: string[]) => void;
@@ -25,13 +27,14 @@ interface UiState {
}
export const useUiStore = create<UiState>((set) => ({
searchQuery: "", activeFolderId: null, activeTagIds: [], activeDbTypes: [], activeView: "home", selectedItemIds: [], prefilledConnectionString: null, activeConnectionId: null,
searchQuery: "", activeFolderId: null, activeTagIds: [], activeDbTypes: [], activeEnvironment: null, activeView: "home", selectedItemIds: [], prefilledConnectionString: null, activeConnectionId: null,
setActiveView: (view) => set({ activeView: view }),
setSearchQuery: (q) => set({ searchQuery: q }),
setActiveFolderId: (id) => set({ activeFolderId: id, selectedItemIds: [] }),
toggleTag: (id) => set((s) => ({ activeTagIds: s.activeTagIds.includes(id) ? s.activeTagIds.filter((t) => t !== id) : [...s.activeTagIds, id] })),
toggleDbType: (type) => set((s) => ({ activeDbTypes: s.activeDbTypes.includes(type) ? s.activeDbTypes.filter((t) => t !== type) : [...s.activeDbTypes, type] })),
clearFilters: () => set({ searchQuery: "", activeTagIds: [], activeDbTypes: [], activeFolderId: null }),
setEnvironment: (env) => set({ activeEnvironment: env }),
clearFilters: () => set({ searchQuery: "", activeTagIds: [], activeDbTypes: [], activeFolderId: null, activeEnvironment: null }),
toggleItemSelection: (id) => set((s) => ({
selectedItemIds: s.selectedItemIds.includes(id)
? s.selectedItemIds.filter((i) => i !== id)