v0.2.0 — Object Explorer, Backup/Restore/Sync, Server-Side Filters, ENUM support, and more

* feat(grid): VirtualDataGrid component with TanStack Virtual (Task A1)

* fix(grid): address code review — header bg, row hover/select, table classes, measureElement removal

* feat(grid): column resize, FK popover, JSON popover in VirtualDataGrid (Task A2)

* fix(grid): address code review — accessibility, indeterminate checkbox, safe jp default

* feat(grid): wire VirtualDataGrid into DbViewerScreen, increase page size to 500 (Task A3)

* feat(grid): polish VirtualDataGrid — large datasets, hidden columns, select-all (Tasks A4+A5)

* feat(dnd): moveConnection store action with optimistic update + rollback (Task D1)

* feat(dnd): draggable connection cards + droppable folder tree (Task D2)

* feat(dnd): wire DndContext, onDragEnd, DragOverlay, drop targets in ConnectionGrid (Tasks D3-D5)

* feat(backup): models, v4 migration for backup_history, TypeScript types (Task C1)

* feat(backup): pg_dump, pg_restore, detect_tools, db_sync commands (Task C2)

* feat(backup): frontend wrappers, backup store, dialogs, tool detection, confirmations (Tasks C3-C5)

* feat(objects): add FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo models (Task B1)

* feat(objects): introspection queries + Tauri commands for functions, triggers, sequences, enums, extensions (Task B2)

* feat(objects): ObjectTree component, sidebar navigation, search, source expand (Tasks B3-B5)

* fix: drag handle position, env tag layout, transparent header, virtual row rendering

* fix: remove table toolbar bg, fix sticky header with flex layout, match header/row widths

* fix: vertical borders on all cells, horizontal border scrolls with header, checkbox column borders

* fix: full-height cell borders, centered checkboxes, horizontal scroll borders

* fix: header bg fills full width on scroll, page sizes match settings (50-500)

* fix: add subtle bg to panel resize handle to eliminate visual gap

* fix: FK preview showing 'db error' + make entire tab clickable

- VirtualDataGrid: added schema prop to fix empty schema sent to FK preview,
  which produced invalid SQL (SELECT * FROM ""."table")
- Rust get_fk_preview: use pg_error_message() instead of .to_string() so
  real PostgreSQL error messages surface instead of just 'db error'
- TabBar: moved onClick to the outer tab container so the entire tab area
  is clickable, not just the table name text

* feat: push filters and sorts to server-side SQL queries

- Added FilterRule/SortRule models to Rust backend with serde support
- Built dynamic WHERE (parameterized /?) and ORDER BY clauses
- Both COUNT(*) and data queries now include filter clauses for
  correct pagination with filtered results
- Store setFilterRules/setSortRules now trigger page-1 re-fetch
- Removed client-side applyFilters/applySorts — data arrives
  pre-filtered/sorted from the database
- Added column name validation (is_safe_identifier) to prevent
  SQL injection in filter/sort column names

* chore: center drag handle vertically on connection cards

* feat: convert backup/restore/sync from unreachable modals to in-page views

- Added BackupPage, RestorePage, SyncPage as full-page views inside
  the DB viewer, replacing the old unused modal dialogs
- Added sidebar navigation items: Backup (Download), Restore (Upload),
  DB Sync (ArrowLeftRight)
- Wired routing in DbViewerScreen — currentView switches to the new
  page components, all scoped to the active connection
- Styled with toolbar header, underline inputs, glass cards, and
  destructive confirmation checkboxes matching the app aesthetic
- Auto-detects pg_dump/pg_restore availability with platform-specific
  install instructions when tools are missing

* fix: backup/restore/sync event-driven flow and styling

- Registered tauri-plugin-dialog in Rust lib.rs (was missing — browse
  buttons were silently failing)
- Added #[serde(rename_all = "camelCase")] to BackupOptions,
  RestoreOptions, SyncOptions so frontend camelCase fields map
  correctly to Rust snake_case fields
- Made backup/restore/sync fully event-driven: pages now derive
  running/completed/failed state from the backupStore instead of
  the pgDump/pgRestore/dbSync promise (which returns immediately
  while the actual work runs in the background)
- Added initListener() call in App.tsx so the backupStore actually
  listens to Tauri backup-progress events
- failJob no longer clears activeJobId so the component can still
  find the job to read its error message
- BackupProgress now receives real job status and error_message;
  visible for all states (running/completed/failed) so the error
  bar renders on failure
- Schema field converted from free-text to database-populated
  dropdown (getSchemas) on all three pages
- Removed unused parseError functions and dead code
- Progress bar shows 100% on completed, 50% indeterminate while
  running, red bar + error text on failure

* feat: object explorer pages with split-pane layout and schema switching

Created ObjectExplorerPage — a reusable split-pane component for
Functions, Triggers, Sequences, Enums, and Extensions:

Left panel:
- Database/schema dropdowns for switching context (solves 'no
  functions showing' when they're in a non-default schema)
- Searchable object list with animated search input
- Refresh button to re-query
- Selected item highlighting

Right panel:
- Detail view with icon, name, schema
- Type-specific fields:
  - Functions: return type, language, kind, arguments (with
    mode/type badges), full source code with show/hide
  - Triggers: table (schema-qualified), event, timing,
    orientation, enabled status, trigger definition
  - Sequences: current value, increment, start, min, max,
    cycle flag
  - Enums: labeled pill badges for each enum value
  - Extensions: version, schema, comment
- Empty state showing count of available objects

Bug fix: pg_get_function_result() replaced with format_type()
in Rust introspection — the former returns multiple rows for
SETOF/TABLE functions (generate_series, etc.), causing the
entire query to fail silently. This was the root cause of
'no functions showing even though I know there are'.

* fix: correct PostgreSQL tgtype bitmask in trigger query

The trigger query used wrong bit positions for all events.
PostgreSQL tgtype bits:
  bit 0 (1): ROW vs STATEMENT
  bit 1 (2): BEFORE vs AFTER
  bit 2 (4): INSERT
  bit 3 (8): DELETE
  bit 4 (16): UPDATE
  bit 5 (32): TRUNCATE

The old query had every event shifted one bit down (INSERT=2
instead of 4, DELETE=4 instead of 8, etc.). Timing checked bit 0
instead of bit 1. Orientation label was inverted (showed STATEMENT
when bit 0=1 which actually means ROW).

* fix: object page re-mount on type switch + matching dropdown style

- Added key={type} to each ObjectExplorerPage instance so React
  forces a fresh mount when switching between Functions → Triggers
  → Sequences → Enums → Extensions (was reusing the same component
  instance, showing stale data from the previous type)
- Replaced raw <select> elements with SelectDropdown variant="ghost"
  to match the Explorer page toolbar style (ghost text buttons with
  chevron and dropdown menus)

* fix: match ObjectExplorerPage sidebar exactly to Explorer page

- Added draggable resize handle between panels (same style as
  Explorer: w-1 cursor-col-resize, bg-border/20, hover/accent)
- Panel width is now resizable (180–500px, double-click resets to
  280px, same as tablePanelWidth)
- Changed list row gap from gap-2 to gap-1 and py-1.5 to py-1
  so list items match TableTree row sizing exactly
- Removed py-1 from the scrollable list container so the first
  item sits flush against the toolbar border (matching TableTree)
- Fixed dropdown separator: gap-3→gap-2, removed text-sm from |
  (now identical to Explorer toolbar dropdown spacing)

* fix: handle overloaded PostgreSQL functions in object explorer

PostgreSQL supports function overloading — same name, different
argument types. The old code used bare name as React key and for
selection comparison, causing:
- Multiple overloads sharing the same key → rendering glitches
  (looked like 'duplicate instances' or missing items)
- Clicking one overload highlighted ALL overloads with that name

Changes:
- itemKey(): unique key using name(arg_type1,arg_type2) for
  functions, bare name for other object types
- itemLabel(): display name with argument signature for functions
  with args (e.g. 'calculate_tax(numeric, integer)')
- Selection comparison uses itemKey equality instead of name
- Search now matches against the full label (name + args)
- Detail view header shows the full label

* feat: card-based detail views with border separators for all object types

Redesigned detail views to match the Explorer page aesthetic:

Functions:
- Signature card: Returns, Language, Kind, Schema in border-separated rows
- Arguments card: each arg shown as 'name: type' with OUT/VARIADIC mode badges
- Source card: header bar with language label, bordered code block

Triggers:
- Details card: Table (schema-qualified), Event, Timing+Orientation,
  Status (color-coded enabled/disabled), Schema
- Definition card with SQL code block

Sequences:
- Compact card: Current Value (highlighted), Increment, Start,
  Min/Max (combined row), Cycle (amber/neutral)

Enums:
- Details card with schema
- Values card with label count, pill badges in rounded-md style

Extensions:
- Card with Version, Schema, optional Comment

Shared: Source code now has bg-surface-raised, rounded-lg, border,
better padding (p-4), and longer default truncation (800 chars).
Removed unused DetailRow helper.

* feat: syntax-highlighted line-numbered source code + flat border layout

SyntaxCode:
- Tokenizes PL/pgSQL/SQL: keywords (blue), types (emerald),
  strings (amber), comments (subtle italic), numbers (purple),
  operators (muted)
- Handles dollar-quoted strings (26325...26325 / $tag$...$tag$),
  single-quoted strings with escape (''), -- line comments,
  /* block comments */
- Line numbers on the left with border-r separator
- Hover row highlight (bg-surface/30)
- Collapsed by default at 60 lines with 'Show all N lines…'
  toggle; expand/collapse button

Flat border layout (matching Explorer table viewer):
- All cards (rounded-lg border bg-surface overflow-hidden)
  replaced with plain divs using border-b border-border rows
- Section headers use same border-b + tracking-wider style as
  the data grid column headers
- Each field row: px-4 py-2 flex with 24/28px label column
- Source code uses bg-canvas (lets app bg show through)
- No space-y-4 gaps or card wrappers — clean flat aesthetic

* fix: infinite loop in tokenizer + memoize syntax highlighting

- Added catch-all fallback for unrecognized characters (non-ASCII,
  unicode, symbols) that weren't matched by any tokenizer branch,
  causing an infinite loop and app hang
- Wrapped tokenization in useMemo to avoid re-tokenizing on every
  render (source and expanded state as deps)
- Pre-tokenize all visible lines at once instead of per-line in
  the render loop
- Added explicit type annotations to fix TS7022/TS7024 errors

* fix: horizontal scroll for long source lines in SyntaxCode

Changed the <pre> wrapper from block to inline-block min-w-full
so it grows past the viewport width when lines are long, enabling
the outer overflow-x-auto to kick in. Added overscroll-x-contain
for smoother scroll behavior on macOS.

* fix: proper horizontal scroll containment for source code

- Right panel: overflow-x-hidden prevents code content from
  pushing the entire panel past the viewport
- SyntaxCode pre: whitespace-pre on the pre itself (not just
  inner spans) so long lines stay on one line
- Removed inline-block min-w-full hack on the pre — instead
  the outer overflow-x-auto div clips and scrolls
- Removed unused overscroll-none class

* fix: horizontal scroll containment in object explorer detail view

- Removed overflow-x-hidden from right panel (was clipping
  scrollbars and preventing proper scroll context)
- Added max-w-full overflow-x-auto on the detail content wrapper
  so long lines scroll within the content area instead of pushing
  the entire panel
- Added w-max min-w-full on the SyntaxCode pre so it grows to
  fit content while the parent overflow-x-auto clips and scrolls

* fix: use w-0 overflow-x-hidden pattern to constrain right panel width

The Explorer page uses 'flex-1 w-0 min-w-0 overflow-hidden' on the
right panel. Without w-0, the flex item can expand past its
allocated share when content is wider. Adding w-0 forces the
initial width to zero so the panel can only grow via flex
allocation, not from content pressure.

Detail content wrapper uses inline overflowX: auto + width: 100%
to create the scroll context for the SyntaxCode pre.

* fix: add overscroll-x-none to source code scroll container

* style: refine object explorer page layout and detail views

* fix: replace enum pill badges with bordered row list

Enum values now display as numbered rows with border-b border-border,
matching the Arguments section style exactly. Each value shows a #N
index in muted mono + the label in accent mono, consistent with how
argument names and types are displayed.

* docs: update implementation status in AGENTS.md and README.md

Object Explorer (formerly all  stubs):
- Functions:  detail view, syntax highlighting, overload support
- Triggers:  detail view, tgtype bitmask fix, definition display
- Sequences:  detail view, schema-filtered
- Enums:  bordered row list matching Arguments style
- Extensions:  detail view, DB-scoped query

Database Viewer:
- Column filtering/sorting: now server-side (was client-side)

Backup & Restore (formerly all ):
- pg_dump/pg_restore wrappers:  with progress events
- Backup/Restore/Sync UI:  in-page views with event-driven status

README roadmap: Phases 3 & 5 marked complete

* fix: SQLite data rows showing null for non-text columns

The SQLite branch of get_table_data always read row values as
Option<String>, which silently failed for INTEGER, REAL, and other
non-text types — returning Null for every non-text column.

Added sqlite_value_to_json() that uses rusqlite's ValueRef enum
(Null/Integer/Real/Text/Blob) to properly convert each column to
the correct serde_json::Value type. Applied to all three query
branches (unfiltered, filtered, and FK preview).

* fix: handle PostgreSQL ENUM and USER-DEFINED types in table viewer

- pg_value_to_json: added robust String fallback that catches enum
  values and any other custom/user-defined types not matched by
  the specific type checks (i32, i64, f64, bool, uuid, etc.)
- pg_columns_query: replaced bare c.data_type with CASE WHEN
  c.data_type = 'USER-DEFINED' THEN c.udt_name ELSE c.data_type
  so enum columns show their actual type name (e.g. badge_category)
  instead of 'USER-DEFINED'
- The get_table_data inline column query already had this fix;
  now pg_columns_query in introspection.rs matches

* fix: cast PostgreSQL custom/enum columns to ::text in data query

tokio-postgres FromSql<String> rejects custom type OIDs (enums,
composite types, domains) even under simple query protocol where
all values arrive as text. This caused all enum column values to
display as NULL.

Fix: dynamically build the SELECT column list by inspecting the
column metadata. For standard PostgreSQL types (int4, text, bool,
jsonb, etc.) use the bare column name. For anything else (custom
enums, domains), append ::text so the value reaches the client as
a regular text column that FromSql<String> accepts.

* chore: replace app icons with 1024x1024 Default variant (proper padding)

* chore: fix Rust warnings — unused import and unnecessary mut

- Removed unused pub use backup::* from models/mod.rs (backup
  types are imported directly via crate::models::backup::*)
- Removed unnecessary mut on filter_params in db_viewer.rs

* chore: bump v0.2.0 + update docs status

AGENTS.md:
- Virtualized data grid:  (row-level virtualization
  via @tanstack/react-virtual)
- Column metadata: noted ENUM/custom type resolution and ::text
  casting for data retrieval

Version bump: 0.1.0 → 0.2.0 across package.json, tauri.conf.json,
and Cargo.toml

* docs: update README features to reflect current implementation

Rewrote aspirational sections with accurate current-state descriptions:

Object Explorer:
- Removed Indexes & Constraints (not yet implemented)
- Added syntax highlighting, line numbers, overload support for Functions
- Clarified Extensions are view-only (no enable/disable toggling yet)

SQL Editor & Query Workbench:
- Removed Monaco, autocomplete, formatter, history (not yet built)
- Kept what's real: multi-tab workspace, changes queue, smart sort
- Added 'coming soon' note

Data Grid:
- Removed inline editing, visual filter builder, import (not yet built)
- Added what's real: server-side filtering, FK preview, JSON viewer

Admin Tools:
- Updated backup/restore/sync descriptions to match actual UI
- Removed aspirational 'drag-and-drop', 'dry-run', 'diff viewer'

Tech Stack:
- Monaco marked as planned; Glide removed (we use TanStack Virtual)
This commit is contained in:
2026-07-29 03:23:52 +08:00
committed by GitHub
parent d872e429e4
commit 3dab09f25d
86 changed files with 7125 additions and 1320 deletions
+15 -14
View File
@@ -217,14 +217,14 @@ cargo test # Rust tests
| Schema/database selector | ✅ | Ghost-style dropdowns, single-row layout |
| Refresh database (spin + success/error feedback) | ✅ | Re-fetches databases, schemas, and tables |
| Search tables filter | ✅ | Animated input, real-time filter by name, auto-hide on blur |
| Column metadata (PK, FK, type, nullable, default) | ✅ | Expand table row to see columns with icons |
| Column metadata (PK, FK, type, nullable, default) | ✅ | Expand table row to see columns with icons. ENUM/custom types resolved via udt_name, cast ::text for data retrieval. |
| FK detection | ✅ | `information_schema.constraint_column_usage` + `PRAGMA foreign_key_list` |
| FK preview popover | ✅ | Click FK cell → popover with referenced row → "Open" button creates filtered tab |
| JSON/JSONB cell popover | ✅ | Formatted/Raw tabs with copy button |
| Smart default sort | ✅ | 12-tier priority: updated_at → created_at → *_at → *_id → seq/rank/version |
| Data grid pagination | ✅ | Page nav, page size selector persisted in settings |
| Column filtering (client-side) | ✅ | eq, neq, contains, starts, ends, gt, lt, null, notnull |
| Column sorting (client-side) | ✅ | Multi-column asc/desc |
| Column filtering (server-side) | ✅ | eq, neq, contains, starts, ends, gt, lt, null, notnull pushed to SQL WHERE |
| Column sorting (server-side) | ✅ | Multi-column asc/desc pushed to SQL ORDER BY |
| Column show/hide | ✅ | Toggle visibility per column |
| Column resize (drag handle) | ✅ | Double-click to auto-fit |
| Row selection (checkboxes + select all) | ✅ | Bulk copy (JSON/CSV/SQL) and delete |
@@ -234,7 +234,7 @@ cargo test # Rust tests
| 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 |
| Virtualized data grid | | Plain HTML `<table>`; TODO: @tanstack/react-virtual for 100k+ rows |
| Virtualized data grid | | Row-level virtualization via @tanstack/react-virtual `useVirtualizer`; handles 100k+ rows |
| Row detail / expandable row view | ❌ | |
| Keyboard cell navigation (arrow keys, Tab) | ❌ | |
| Cell-level copy (right-click or Ctrl+C) | ❌ | Only bulk copy via toolbar |
@@ -242,15 +242,15 @@ cargo test # Rust tests
### Object Explorer (non-table objects)
| Feature | Status | Details |
| :--- | :---: | :--- |
| Functions | | Stub button in sidebar; not queried from `pg_proc` |
| Triggers | | Stub button in sidebar |
| Sequences | | Not listed anywhere |
| Enums / user-defined types | ❌ | `udt_name` returned in column metadata but no enum viewer |
| Extensions | | Not queried from `pg_extension` |
| Functions | | Full detail view: signature, arguments with mode/type, syntax-highlighted line-numbered source. Overloads disambiguated by argument signature. Schema-filtered via pg_proc query. |
| Triggers | | Full detail view: table, event, timing, orientation, status (color-coded), definition. tgtype bitmask corrected. Schema-filtered. |
| Sequences | | Full detail view: current value, increment, start, min/max, cycle flag. Schema-filtered via information_schema.sequences. |
| Enums | ✅ | Full detail view: numbered bordered list matching Arguments style. Schema-filtered via pg_type WHERE typtype='e'. |
| Extensions | | Full detail view: version, schema, comment. Queried from pg_extension (no schema filter — extensions are DB-scoped). |
| Indexes (per table) | ❌ | |
| Constraints (CHECK, UNIQUE beyond PK/FK) | ❌ | |
| Materialized views | ❌ | Not distinguished from regular views |
| Stored procedures | | |
| Stored procedures | 🟡 | Included in Functions via p.prokind IN ('f','p'); no separate view yet |
| Schema visualizer (ER diagram) | ❌ | Stub button in sidebar |
### Query Editor
@@ -268,10 +268,11 @@ cargo test # Rust tests
### Backup & Restore
| Feature | Status | Details |
| :--- | :---: | :--- |
| pg_dump wrapper | | No Rust command; shell out to system binary per design decision #3 |
| pg_restore wrapper | | |
| Backup UI | | |
| DB-to-DB sync | ❌ | |
| pg_dump wrapper | | Rust command spawns pg_dump with real-time progress events (backup-progress) |
| pg_restore wrapper | | Rust command spawns pg_restore with progress events |
| Backup UI | | In-page view: format selector, file browse (Tauri dialog), schema dropdown, no-owner toggle, progress bar with event-driven status |
| Restore UI | ✅ | In-page view: file browse, format, clean toggle, destructive confirmation checkbox, progress bar |
| DB-to-DB sync | ✅ | In-page view: source/target connection pickers, schema dropdown, pipe-based pg_dump → pg_restore |
| SQLite .dump | ❌ | |
| Table structure export (DDL) | ❌ | |
+28 -26
View File
@@ -34,31 +34,32 @@ Most database GUI clients either lock essential productivity features behind pay
### PostgreSQL Object Explorer
Full tree-view navigation of all native PostgreSQL schema objects:
- **Tables & Views** — columns, types, defaults, nullability, primary/foreign keys, indexes
- **Functions & Procedures** — source code with syntax highlighting, argument signatures, return types
- **Triggers & Rules** — event bindings (`BEFORE/AFTER INSERT/UPDATE/DELETE`) with inline definition inspection
- **Sequences & Enums** — current values, increments, custom enum options
- **Indexes & Constraints** — usage stats, composite keys, `UNIQUE` / `CHECK` definitions
- **Extensions** — installed extensions view (`pgvector`, `uuid-ossp`, `postgis`) with enable/disable toggling
- **Tables & Views** — columns, types, defaults, nullability, primary/foreign keys with popover preview
- **Functions & Procedures** — source code with syntax highlighting and line numbers, argument signatures, return types, overload support
- **Triggers & Rules** — event bindings with inline definition inspection, color-coded enabled/disabled status
- **Sequences & Enums** — current values, increments, cycle flags; enum labels in bordered list view
- **Extensions** — installed extensions with version, schema, and comment
### SQL Editor & Query Workbench
- **Monaco Editor** — full SQL syntax highlighting, auto-indentation, error markers
- **Context-aware Autocomplete** — real-time schema introspection suggests tables, columns, and function signatures as you type
- **Query Formatter** — clean, styled display with keyword highlighting and code folding
- **History & Snippets** — automatic query logging with timestamps and execution duration; unlimited saved snippets organized by folder
- **Multi-Tab Workspace** — unlimited named tabs, drag-and-drop reorder, session persistence across restarts
- **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
- **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 coming soon)*
### Data Grid & Schema Browser
- **Virtualized Grid** — canvas/DOM-virtualized rendering handles 100k+ rows at 60fps (Glide Data Grid / TanStack Virtual)
- **Inline Editing** — double-click cells to edit, delete rows, or insert records directly
- **Visual Filter Builder** — multi-column filters without writing raw SQL
- **Export** — CSV, JSON, NDJSON, Excel, raw `INSERT` statements
- **Import** — load CSV/JSON files into tables with visual column mapping
- **Virtualized Grid** — row-level virtualization via `@tanstack/react-virtual` handles 100k+ rows
- **Column Management** — resize with drag handles (double-click to auto-fit), show/hide per column, multi-column sort
- **Server-Side Filtering & Sorting** — filters and sorts pushed to SQL WHERE/ORDER BY
- **Export** — JSON, CSV, SQL, Markdown via toolbar
- **FK Preview** — click a foreign key cell to preview the referenced row
- **JSON/JSONB Viewer** — popover with formatted/raw tabs and copy button
- **Auto-Refresh** — configurable interval timer
- *(Inline cell editing, visual filter builder, and data import coming soon)*
### PostgreSQL Administrative Tools
- **Visual Backup** — one-click `pg_dump` wrapper: Plain SQL, Custom, or Tar format; scope by full DB, schema-only, data-only, or specific tables
- **Visual Restore** — drag-and-drop `pg_restore` with dry-run mode and detailed error reporting
- **DB-to-DB Sync** — migrate data between environments with a table diff viewer showing inserted, modified, and missing rows before committing
- **Visual Backup** — `pg_dump` wrapper with format selector (Plain SQL, Custom, Tar, Directory), file browser, schema filter, no-owner toggle, real-time progress bar
- **Visual Restore** — `pg_restore` wrapper with file browser, format, clean toggle, destructive confirmation checkbox
- **DB-to-DB Sync** — pipe `pg_dump``pg_restore` between two connections with source/target pickers, schema filter, flow indicator
### App Portability
- **Export** — save all workspaces, folders, saved queries, tags, and non-sensitive metadata to a single JSON archive
@@ -77,8 +78,8 @@ 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** | [Monaco Editor](https://microsoft.github.io/monaco-editor/) | IDE-grade SQL editing with autocomplete |
| **Data Grid** | [Glide Data Grid](https://grid.glideapps.com) / [TanStack Virtual](https://tanstack.com/virtual) | Virtualized 60fps table rendering |
| **Code Editor** | *(planned)* [Monaco Editor](https://microsoft.github.io/monaco-editor/) | IDE-grade SQL editing with autocomplete (coming soon) |
| **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 |
---
@@ -172,17 +173,18 @@ gridline/
- [ ] OS Keychain credential storage
3. **Phase 3 — Schema Explorer**
- [ ] PostgreSQL `pg_catalog` / `information_schema` introspection
- [ ] Full object tree (Tables, Views, Functions, Triggers, Enums, Sequences)
- [x] PostgreSQL `pg_catalog` / `information_schema` introspection
- [x] Full object tree (Tables, Views, Functions, Triggers, Enums, Sequences, Extensions)
- [x] Per-type detail views with source code, arguments, metadata
4. **Phase 4 — Query Workbench**
- [ ] Monaco Editor integration with SQL autocomplete
- [ ] Virtualized data grid for query results
- [x] Virtualized data grid for query results
- [ ] Query history & saved snippets
5. **Phase 5 — Admin Tools**
- [ ] `pg_dump` / `pg_restore` UI wrappers
- [ ] DB-to-DB schema & data sync
- [x] `pg_dump` / `pg_restore` UI wrappers
- [x] DB-to-DB schema & data sync
6. **Phase 6 — Multi-Database Support**
- [ ] MySQL driver
+13
View File
@@ -5,9 +5,12 @@
"": {
"name": "gridline",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource/outfit": "^5.3.0",
"@fontsource/space-mono": "^5.3.0",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-virtual": "^3.14.8",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.2",
"@tauri-apps/plugin-fs": "^2.5.1",
@@ -99,6 +102,12 @@
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="],
"@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
"@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="],
"@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
@@ -251,6 +260,10 @@
"@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="],
"@tanstack/react-virtual": ["@tanstack/react-virtual@3.14.8", "", { "dependencies": { "@tanstack/virtual-core": "3.17.6" }, "peerDependencies": { "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-O39GJQpAYEJcIu3uN1//YtmhjSEOyw75vg9CKCatBDPiD5hKtZQoJHfferyrB/LdOD3UWaoMLWtdEjarwIwdDw=="],
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.6", "", {}, "sha512-h0/Ebo18CkOrChlQIhNtQkM5ySUnh/GumQ/D1st3hG2HWUPEF+ILUc2k29UtivCi/9G7w7G3/f7Xyd5cCFbKBw=="],
"@tauri-apps/api": ["@tauri-apps/api@2.11.1", "", {}, "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA=="],
"@tauri-apps/cli": ["@tauri-apps/cli@2.11.4", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.11.4", "@tauri-apps/cli-darwin-x64": "2.11.4", "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4", "@tauri-apps/cli-linux-arm64-gnu": "2.11.4", "@tauri-apps/cli-linux-arm64-musl": "2.11.4", "@tauri-apps/cli-linux-riscv64-gnu": "2.11.4", "@tauri-apps/cli-linux-x64-gnu": "2.11.4", "@tauri-apps/cli-linux-x64-musl": "2.11.4", "@tauri-apps/cli-win32-arm64-msvc": "2.11.4", "@tauri-apps/cli-win32-ia32-msvc": "2.11.4", "@tauri-apps/cli-win32-x64-msvc": "2.11.4" }, "bin": { "tauri": "tauri.js" } }, "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ=="],
+4 -1
View File
@@ -1,7 +1,7 @@
{
"name": "gridline",
"private": true,
"version": "0.1.0",
"version": "0.2.0",
"description": "An open-source, high-performance database GUI client for PostgreSQL and beyond",
"type": "module",
"scripts": {
@@ -14,9 +14,12 @@
"tauri": "tauri"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource/outfit": "^5.3.0",
"@fontsource/space-mono": "^5.3.0",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-virtual": "^3.14.8",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.2",
"@tauri-apps/plugin-fs": "^2.5.1",
+1 -1
View File
@@ -1763,7 +1763,7 @@ dependencies = [
[[package]]
name = "gridline"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"chrono",
"deadpool-postgres",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "gridline"
version = "0.1.0"
version = "0.2.0"
description = "An open-source, high-performance database GUI client for PostgreSQL and beyond"
authors = ["you"]
edition = "2021"
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

+32
View File
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 1000 1000" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;">
<g transform="matrix(1.464129,0,0,1.464129,-67.349927,-273.792094)">
<path d="M729,357.75L729,699.25C729,793.489 652.489,870 558.25,870L216.75,870C122.511,870 46,793.489 46,699.25L46,357.75C46,263.511 122.511,187 216.75,187L558.25,187C652.489,187 729,263.511 729,357.75Z" style="fill:url(#_Linear1);stroke:black;stroke-width:0.68px;"/>
</g>
<g id="Icon">
<g transform="matrix(1.186992,0,0,1.186992,-93.495935,-96.300813)">
<path d="M325,351C325,305.743 403.415,269 500,269C596.585,269 675,305.743 675,351C675,365.931 666.465,379.936 651.557,392C640.124,401.252 624.943,409.364 606.99,415.876C577.4,426.609 540.28,433 500,433C459.711,433 422.583,426.607 392.99,415.869C375.046,409.358 359.872,401.249 348.443,392C333.535,379.936 325,365.931 325,351Z" style="fill:white;stroke:black;stroke-width:14.04px;"/>
</g>
<g transform="matrix(1.186992,0,0,1.186992,-93.495935,-96.300813)">
<path d="M325,433L325,351C325,365.931 333.535,379.936 348.443,392C359.872,401.249 375.046,409.358 392.99,415.869L392.99,497.869C375.046,491.358 359.872,483.249 348.443,474C333.535,461.936 325,447.931 325,433Z" style="fill:white;stroke:black;stroke-width:14.04px;"/>
</g>
<path d="M626.996,592.007C591.874,604.748 547.812,612.333 500,612.333L500,515C547.812,515 591.874,507.414 626.996,494.674C648.307,486.944 666.326,477.316 679.897,466.333C697.592,452.013 707.724,435.39 707.724,417.667L707.724,515C707.724,532.723 697.592,549.347 679.897,563.667C666.326,574.649 648.307,584.277 626.996,592.007Z" style="fill:white;stroke:black;stroke-width:16.67px;"/>
<g transform="matrix(1.186992,0,0,1.186992,-93.495935,-96.300813)">
<path d="M325,515L325,433C325,447.931 333.535,461.936 348.443,474C359.872,483.249 375.046,491.358 392.99,497.869L392.99,579.869C375.046,573.358 359.872,565.249 348.443,556C333.535,543.936 325,529.931 325,515Z" style="fill:white;stroke:black;stroke-width:14.04px;"/>
</g>
<g transform="matrix(1.186992,0,0,1.186992,-93.495935,-96.300813)">
<path d="M325,597L325,515C325,529.931 333.535,543.936 348.443,556C359.872,565.249 375.046,573.358 392.99,579.869L392.99,661.869C375.046,655.358 359.872,647.249 348.443,638C333.535,625.936 325,611.931 325,597Z" style="fill:white;stroke:black;stroke-width:14.04px;"/>
</g>
<g transform="matrix(1.186992,0,0,1.186992,-93.495935,-96.300813)">
<path d="M675,515L675,597C675,611.931 666.465,625.936 651.557,638C640.124,647.252 624.943,655.364 606.99,661.876L606.99,579.876C624.943,573.364 640.124,565.252 651.557,556C666.465,543.936 675,529.931 675,515Z" style="fill:white;stroke:black;stroke-width:14.04px;"/>
</g>
<g transform="matrix(1.186992,0,0,1.186992,-93.495935,-96.300813)">
<path d="M675,597L675,679C675,705.383 648.352,728.872 606.99,743.876L606.99,661.876C624.943,655.364 640.124,647.252 651.557,638C666.465,625.936 675,611.931 675,597Z" style="fill:white;stroke:black;stroke-width:14.04px;"/>
</g>
<path d="M372.98,786.665C323.898,768.856 292.276,740.978 292.276,709.667L292.276,612.333C292.276,630.057 302.408,646.68 320.103,661C333.669,671.979 351.68,681.603 372.98,689.332C408.107,702.078 452.177,709.667 500,709.667C547.812,709.667 591.874,702.081 626.996,689.341L626.996,786.674C591.874,799.414 547.812,807 500,807C452.177,807 408.107,799.411 372.98,786.665Z" style="fill:white;stroke:black;stroke-width:16.67px;"/>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(683,683,-683,683,46,187)"><stop offset="0" style="stop-color:rgb(108,108,108);stop-opacity:1"/><stop offset="0.17" style="stop-color:rgb(15,15,15);stop-opacity:1"/><stop offset="0.5" style="stop-color:black;stop-opacity:1"/><stop offset="0.83" style="stop-color:rgb(15,15,15);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(108,108,108);stop-opacity:1"/></linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 1002 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 79 KiB

+577
View File
@@ -0,0 +1,577 @@
use std::process::{Command, Stdio};
use tauri::{AppHandle, Emitter, State};
use crate::models::backup::*;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn get_version(tool: &str) -> Option<String> {
Command::new(tool)
.arg("--version")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
}
// ---------------------------------------------------------------------------
// detect_pg_tools
// ---------------------------------------------------------------------------
#[tauri::command]
pub fn detect_pg_tools() -> PgToolStatus {
PgToolStatus {
pg_dump_found: Command::new("pg_dump").arg("--version").output().is_ok(),
pg_restore_found: Command::new("pg_restore").arg("--version").output().is_ok(),
pg_dump_version: get_version("pg_dump"),
pg_restore_version: get_version("pg_restore"),
}
}
// ---------------------------------------------------------------------------
// pg_dump
// ---------------------------------------------------------------------------
#[tauri::command]
pub async fn pg_dump(
connection_id: String,
options: BackupOptions,
state: State<'_, crate::AppState>,
app_handle: AppHandle,
) -> Result<String, String> {
let job_id = uuid::Uuid::new_v4().to_string();
// Get connection from store (scope the std::sync::Mutex lock guard)
let conn = {
let store = state
.db_store
.lock()
.map_err(|e| e.to_string())?;
let connections = store.get_connections().map_err(|e| e.to_string())?;
connections
.into_iter()
.find(|c| c.id == connection_id)
.ok_or_else(|| format!("Connection not found: {connection_id}"))?
};
// Get password from keychain
let password = crate::commands::keychain::get_connection_password_internal(
&app_handle,
&connection_id,
)
.unwrap_or_default()
.unwrap_or_default();
// Extract connection fields before moving into spawn_blocking
let host = conn.host.clone();
let port = conn.port.unwrap_or(5432);
let username = conn.username.unwrap_or_else(|| "postgres".into());
let database = conn.database.unwrap_or_else(|| "postgres".into());
let file_path = options.file_path.clone();
let format = options.format.clone();
let no_owner = options.no_owner;
let schema = options.schema.clone();
let tables = options.tables.clone();
let job_id_clone = job_id.clone();
tokio::task::spawn_blocking(move || {
let mut args: Vec<String> = vec![
format!("--host={host}"),
format!("--port={port}"),
format!("--username={username}"),
format!("--dbname={database}"),
];
match format.as_str() {
"custom" => args.push("--format=c".into()),
"tar" => args.push("--format=t".into()),
"directory" => args.push("--format=d".into()),
_ => {} // "plain" is the default — no format flag needed
}
if no_owner {
args.push("--no-owner".into());
}
if let Some(ref schema) = schema {
args.push(format!("--schema={schema}"));
}
if let Some(ref tables) = tables {
for t in tables {
args.push(format!("--table={t}"));
}
}
args.push(format!("--file={file_path}"));
let result = Command::new("pg_dump")
.env("PGPASSWORD", &password)
.args(&args)
.output();
match result {
Ok(output) if output.status.success() => {
let _ = app_handle.emit(
"backup-progress",
BackupProgressEvent {
job_id: job_id_clone.clone(),
status: "completed".into(),
progress: Some(1.0),
output_line: None,
error: None,
},
);
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let _ = app_handle.emit(
"backup-progress",
BackupProgressEvent {
job_id: job_id_clone.clone(),
status: "failed".into(),
progress: None,
output_line: None,
error: Some(
crate::commands::test_connection::sanitize_error(&stderr),
),
},
);
}
Err(e) => {
let _ = app_handle.emit(
"backup-progress",
BackupProgressEvent {
job_id: job_id_clone.clone(),
status: "failed".into(),
progress: None,
output_line: None,
error: Some(e.to_string()),
},
);
}
}
});
Ok(job_id)
}
// ---------------------------------------------------------------------------
// pg_restore
// ---------------------------------------------------------------------------
#[tauri::command]
pub async fn pg_restore(
connection_id: String,
options: RestoreOptions,
state: State<'_, crate::AppState>,
app_handle: AppHandle,
) -> Result<String, String> {
let job_id = uuid::Uuid::new_v4().to_string();
let conn = {
let store = state
.db_store
.lock()
.map_err(|e| e.to_string())?;
let connections = store.get_connections().map_err(|e| e.to_string())?;
connections
.into_iter()
.find(|c| c.id == connection_id)
.ok_or_else(|| format!("Connection not found: {connection_id}"))?
};
let password = crate::commands::keychain::get_connection_password_internal(
&app_handle,
&connection_id,
)
.unwrap_or_default()
.unwrap_or_default();
let host = conn.host.clone();
let port = conn.port.unwrap_or(5432);
let username = conn.username.unwrap_or_else(|| "postgres".into());
let database = conn.database.unwrap_or_else(|| "postgres".into());
let file_path = options.file_path.clone();
let format = options.format.clone();
let clean = options.clean;
let schema = options.schema.clone();
let job_id_clone = job_id.clone();
tokio::task::spawn_blocking(move || {
let mut args: Vec<String> = vec![
format!("--host={host}"),
format!("--port={port}"),
format!("--username={username}"),
format!("--dbname={database}"),
];
match format.as_str() {
"custom" => args.push("--format=c".into()),
"tar" => args.push("--format=t".into()),
"directory" => args.push("--format=d".into()),
_ => {}
}
if clean {
args.push("--clean".into());
args.push("--if-exists".into());
}
if let Some(ref schema) = schema {
args.push(format!("--schema={schema}"));
}
args.push(file_path.clone());
let result = Command::new("pg_restore")
.env("PGPASSWORD", &password)
.args(&args)
.output();
match result {
Ok(output) if output.status.success() => {
let _ = app_handle.emit(
"backup-progress",
BackupProgressEvent {
job_id: job_id_clone.clone(),
status: "completed".into(),
progress: Some(1.0),
output_line: None,
error: None,
},
);
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let _ = app_handle.emit(
"backup-progress",
BackupProgressEvent {
job_id: job_id_clone.clone(),
status: "failed".into(),
progress: None,
output_line: None,
error: Some(
crate::commands::test_connection::sanitize_error(&stderr),
),
},
);
}
Err(e) => {
let _ = app_handle.emit(
"backup-progress",
BackupProgressEvent {
job_id: job_id_clone.clone(),
status: "failed".into(),
progress: None,
output_line: None,
error: Some(e.to_string()),
},
);
}
}
});
Ok(job_id)
}
// ---------------------------------------------------------------------------
// db_sync (pg_dump | pg_restore via Unix pipe)
// ---------------------------------------------------------------------------
#[tauri::command]
pub async fn db_sync(
options: SyncOptions,
state: State<'_, crate::AppState>,
app_handle: AppHandle,
) -> Result<String, String> {
let job_id = uuid::Uuid::new_v4().to_string();
// Get both connections from store
let (source_conn, target_conn) = {
let store = state
.db_store
.lock()
.map_err(|e| e.to_string())?;
let connections = store.get_connections().map_err(|e| e.to_string())?;
let src = connections
.iter()
.find(|c| c.id == options.source_connection_id)
.ok_or_else(|| {
format!(
"Source connection not found: {}",
options.source_connection_id
)
})?
.clone();
let tgt = connections
.iter()
.find(|c| c.id == options.target_connection_id)
.ok_or_else(|| {
format!(
"Target connection not found: {}",
options.target_connection_id
)
})?
.clone();
(src, tgt)
};
// Get passwords
let src_password = crate::commands::keychain::get_connection_password_internal(
&app_handle,
&source_conn.id,
)
.unwrap_or_default()
.unwrap_or_default();
let tgt_password = crate::commands::keychain::get_connection_password_internal(
&app_handle,
&target_conn.id,
)
.unwrap_or_default()
.unwrap_or_default();
// Extract connection fields
let src_host = source_conn.host.clone();
let src_port = source_conn.port.unwrap_or(5432);
let src_username = source_conn
.username
.clone()
.unwrap_or_else(|| "postgres".into());
let src_database = source_conn
.database
.clone()
.unwrap_or_else(|| "postgres".into());
let tgt_host = target_conn.host.clone();
let tgt_port = target_conn.port.unwrap_or(5432);
let tgt_username = target_conn
.username
.clone()
.unwrap_or_else(|| "postgres".into());
let tgt_database = target_conn
.database
.clone()
.unwrap_or_else(|| "postgres".into());
let schema = options.schema.clone();
let tables = options.tables.clone();
let job_id_clone = job_id.clone();
tokio::task::spawn_blocking(move || {
// --- Build pg_dump args ---
let mut dump_args: Vec<String> = vec![
format!("--host={src_host}"),
format!("--port={src_port}"),
format!("--username={src_username}"),
format!("--dbname={src_database}"),
"--format=c".into(), // binary custom format for reliable piping
"--no-owner".into(),
];
if let Some(ref schema) = schema {
dump_args.push(format!("--schema={schema}"));
}
if let Some(ref tables) = tables {
for t in tables {
dump_args.push(format!("--table={t}"));
}
}
// --- Build pg_restore args ---
let restore_args: Vec<String> = vec![
format!("--host={tgt_host}"),
format!("--port={tgt_port}"),
format!("--username={tgt_username}"),
format!("--dbname={tgt_database}"),
"--no-owner".into(),
];
// --- Spawn pg_dump with piped stdout ---
let mut dump_child = match Command::new("pg_dump")
.env("PGPASSWORD", &src_password)
.args(&dump_args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(child) => child,
Err(e) => {
let _ = app_handle.emit(
"backup-progress",
BackupProgressEvent {
job_id: job_id_clone.clone(),
status: "failed".into(),
progress: None,
output_line: None,
error: Some(format!("Failed to start pg_dump: {e}")),
},
);
return;
}
};
let dump_stdout = dump_child.stdout.take().unwrap();
let dump_stderr_reader = dump_child.stderr.take().unwrap();
// Read pg_dump stderr in a separate thread so the pipe doesn't block
let dump_stderr_handle = std::thread::spawn(move || {
use std::io::Read;
let mut buf = String::new();
let _ = dump_stderr_reader
.take(10 * 1024 * 1024) // cap at 10 MiB
.read_to_string(&mut buf);
buf
});
// --- Run pg_restore with pg_dump stdout as stdin ---
let restore_result = Command::new("pg_restore")
.env("PGPASSWORD", &tgt_password)
.args(&restore_args)
.stdin(dump_stdout)
.output();
// Wait for pg_dump to finish
let dump_status = dump_child.wait();
let dump_stderr = dump_stderr_handle.join().unwrap_or_default();
// --- Check results ---
let dump_failed = match dump_status {
Ok(status) => !status.success(),
Err(_) => true,
};
if dump_failed {
let _ = app_handle.emit(
"backup-progress",
BackupProgressEvent {
job_id: job_id_clone.clone(),
status: "failed".into(),
progress: None,
output_line: None,
error: Some(format!(
"pg_dump failed: {}",
crate::commands::test_connection::sanitize_error(&dump_stderr)
)),
},
);
return;
}
match restore_result {
Ok(output) if output.status.success() => {
let _ = app_handle.emit(
"backup-progress",
BackupProgressEvent {
job_id: job_id_clone.clone(),
status: "completed".into(),
progress: Some(1.0),
output_line: None,
error: None,
},
);
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let _ = app_handle.emit(
"backup-progress",
BackupProgressEvent {
job_id: job_id_clone.clone(),
status: "failed".into(),
progress: None,
output_line: None,
error: Some(format!(
"pg_restore failed: {}",
crate::commands::test_connection::sanitize_error(&stderr)
)),
},
);
}
Err(e) => {
let _ = app_handle.emit(
"backup-progress",
BackupProgressEvent {
job_id: job_id_clone.clone(),
status: "failed".into(),
progress: None,
output_line: None,
error: Some(format!("pg_restore failed: {e}")),
},
);
}
}
});
Ok(job_id)
}
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
/// Builds command-line args using hardcoded values for testing.
/// Mirrors the logic used by `pg_dump` and `pg_restore` commands.
#[cfg(test)]
pub(crate) fn build_args_for_test(
tool: &str,
dbname: &str,
format: &str,
file_path: &str,
no_owner: bool,
schema: Option<&str>,
tables: Option<Vec<&str>>,
) -> Vec<String> {
let mut args: Vec<String> = vec![
"--host=localhost".into(),
"--port=5432".into(),
"--username=postgres".into(),
format!("--dbname={dbname}"),
];
match format {
"custom" => args.push("--format=c".into()),
"tar" => args.push("--format=t".into()),
"directory" => args.push("--format=d".into()),
_ => {} // "plain" is default
}
if no_owner {
args.push("--no-owner".into());
}
if let Some(ref schema) = schema {
args.push(format!("--schema={schema}"));
}
if let Some(ref tables) = tables {
for t in tables {
args.push(format!("--table={t}"));
}
}
if tool == "pg_dump" {
args.push(format!("--file={file_path}"));
} else {
// pg_restore
args.push(file_path.into());
}
args
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
#[path = "backup.test.rs"]
mod tests;
+188
View File
@@ -0,0 +1,188 @@
use super::*;
// ------------------------------------------------------------------
// build_args_for_test (unit tests for arg construction logic)
// ------------------------------------------------------------------
#[test]
fn build_pg_dump_args_plain_format() {
let args = build_args_for_test(
"pg_dump",
"mydb",
"plain",
"/tmp/dump.sql",
true,
None,
None,
);
assert!(
args.iter().any(|a| a.contains("--no-owner")),
"should include --no-owner"
);
assert!(
args.iter().any(|a| a == "--file=/tmp/dump.sql"),
"should include --file flag"
);
// plain format should NOT add a --format flag
assert!(
!args.iter().any(|a| a.starts_with("--format")),
"plain format should not emit --format"
);
}
#[test]
fn build_pg_dump_args_custom_format() {
let args = build_args_for_test(
"pg_dump",
"mydb",
"custom",
"/tmp/dump.bak",
true,
Some("public"),
None,
);
assert!(
args.iter().any(|a| a == "--format=c"),
"custom format should emit --format=c"
);
assert!(
args.iter().any(|a| a == "--schema=public"),
"should include --schema flag"
);
}
#[test]
fn build_pg_dump_args_tar_format() {
let args = build_args_for_test(
"pg_dump",
"testdb",
"tar",
"/tmp/test.tar",
false,
None,
None,
);
assert!(
args.iter().any(|a| a == "--format=t"),
"should emit --format=t"
);
assert!(
!args.iter().any(|a| a.contains("--no-owner")),
"should NOT include --no-owner when false"
);
}
#[test]
fn build_pg_dump_args_directory_format() {
let args = build_args_for_test(
"pg_dump",
"proddb",
"directory",
"/tmp/dumpdir",
false,
None,
Some(vec!["users", "orders"]),
);
assert!(
args.iter().any(|a| a == "--format=d"),
"should emit --format=d"
);
assert!(args.iter().any(|a| a == "--table=users"));
assert!(args.iter().any(|a| a == "--table=orders"));
}
#[test]
fn build_pg_restore_args() {
let args = build_args_for_test(
"pg_restore",
"targetdb",
"custom",
"/tmp/dump.bak",
false,
None,
None,
);
// pg_restore should NOT emit --file=, it should pass the path as positional
assert!(
!args.iter().any(|a| a.starts_with("--file")),
"pg_restore should not use --file flag"
);
assert!(
args.iter().any(|a| a == "/tmp/dump.bak"),
"pg_restore should include file path as positional arg"
);
}
#[test]
fn build_pg_restore_args_with_schema() {
let args = build_args_for_test(
"pg_restore",
"mydb",
"plain",
"/tmp/dump.sql",
false,
Some("public"),
None,
);
assert!(args.iter().any(|a| a == "--schema=public"));
}
// ------------------------------------------------------------------
// detect_pg_tools
// ------------------------------------------------------------------
#[test]
fn detect_pg_tools_does_not_panic() {
let status = detect_pg_tools();
// May or may not find tools, but the call itself must not panic
let _ = status.pg_dump_found;
let _ = status.pg_restore_found;
let _ = status.pg_dump_version;
let _ = status.pg_restore_version;
}
#[test]
fn pg_tool_status_serialization() {
let status = PgToolStatus {
pg_dump_found: true,
pg_restore_found: false,
pg_dump_version: Some("pg_dump (PostgreSQL) 16.0".into()),
pg_restore_version: None,
};
let json = serde_json::to_string(&status).unwrap();
assert!(json.contains("pg_dump_found"));
assert!(json.contains("pg_restore_found"));
assert!(json.contains("pg_dump (PostgreSQL) 16.0"));
}
// ------------------------------------------------------------------
// BackupProgressEvent serialization
// ------------------------------------------------------------------
#[test]
fn backup_progress_event_completed() {
let evt = BackupProgressEvent {
job_id: "job-1".into(),
status: "completed".into(),
progress: Some(1.0),
output_line: None,
error: None,
};
let json = serde_json::to_string(&evt).unwrap();
assert!(json.contains("\"completed\""));
assert!(json.contains("\"progress\":1.0"));
}
#[test]
fn backup_progress_event_failed() {
let evt = BackupProgressEvent {
job_id: "job-2".into(),
status: "failed".into(),
progress: None,
output_line: None,
error: Some("connection refused".into()),
};
let json = serde_json::to_string(&evt).unwrap();
assert!(json.contains("\"failed\""));
assert!(json.contains("\"connection refused\""));
}
+407 -38
View File
@@ -3,8 +3,11 @@
//! This module provides pure SQL builder functions, pagination helpers,
//! and Tauri commands for the database viewer.
use crate::db::pool::DbConfig;
use crate::models::db_viewer::{Change, ColumnInfo, QueryResult, TableInfo};
use crate::db::pool::{DbConfig, DbHandle};
use crate::models::db_viewer::{
Change, ColumnInfo, EnumInfo, ExtensionInfo, FunctionInfo, QueryResult,
SequenceInfo, TableInfo, TriggerInfo,
};
use std::collections::HashMap;
use tauri::State;
use tokio_postgres::types::ToSql;
@@ -87,6 +90,122 @@ pub fn offset(page: i64, page_size: i64) -> i64 {
(page - 1) * page_size
}
// ---------------------------------------------------------------------------
// Filter / Sort → SQL helpers
// ---------------------------------------------------------------------------
/// Returns true when the column name contains only safe identifier characters.
fn is_safe_identifier(col: &str) -> bool {
!col.is_empty() && col.chars().all(|c| c.is_alphanumeric() || c == '_')
}
/// Build a WHERE clause from filter rules for PostgreSQL (parameterized $n).
/// Returns `(where_clause, param_values)` where `where_clause` starts with
/// " AND " (suitable for appending after WHERE 1=1).
fn build_pg_filter_clause(
filters: &[crate::models::db_viewer::FilterRule],
param_start: &mut usize,
) -> (String, Vec<String>) {
let mut clauses = String::new();
let mut params: Vec<String> = Vec::new();
for rule in filters {
let col = &rule.column;
if !is_safe_identifier(col) {
continue; // skip unsafe column names
}
let clause = match rule.operator.as_str() {
"null" => {
format!(" AND \"{}\" IS NULL", col)
}
"notnull" => {
format!(" AND \"{}\" IS NOT NULL", col)
}
op @ ("eq" | "neq" | "contains" | "starts" | "ends" | "gt" | "lt") => {
*param_start += 1;
let p = *param_start;
params.push(rule.value.clone());
match op {
"eq" => format!(" AND \"{}\"::text = ${}", col, p),
"neq" => format!(" AND \"{}\"::text != ${}", col, p),
"contains" => format!(" AND \"{}\"::text ILIKE '%' || ${} || '%'", col, p),
"starts" => format!(" AND \"{}\"::text ILIKE ${} || '%'", col, p),
"ends" => format!(" AND \"{}\"::text ILIKE '%' || ${}", col, p),
"gt" => format!(" AND \"{}\"::numeric > ${}::numeric", col, p),
"lt" => format!(" AND \"{}\"::numeric < ${}::numeric", col, p),
_ => unreachable!(),
}
}
_ => continue, // unknown operator → skip
};
clauses.push_str(&clause);
}
(clauses, params)
}
/// Build a WHERE clause from filter rules for SQLite (positional ? params).
fn build_sqlite_filter_clause(filters: &[crate::models::db_viewer::FilterRule]) -> (String, Vec<String>) {
let mut clauses = String::new();
let mut params: Vec<String> = Vec::new();
for rule in filters {
let col = &rule.column;
if !is_safe_identifier(col) {
continue;
}
let clause = match rule.operator.as_str() {
"null" => {
format!(" AND \"{}\" IS NULL", col)
}
"notnull" => {
format!(" AND \"{}\" IS NOT NULL", col)
}
op @ ("eq" | "neq" | "contains" | "starts" | "ends" | "gt" | "lt") => {
params.push(rule.value.clone());
match op {
"eq" => format!(" AND \"{}\" = ?", col),
"neq" => format!(" AND \"{}\" != ?", col),
"contains" => format!(" AND \"{}\" LIKE '%' || ? || '%'", col),
"starts" => format!(" AND \"{}\" LIKE ? || '%'", col),
"ends" => format!(" AND \"{}\" LIKE '%' || ?", col),
"gt" => format!(" AND CAST(\"{}\" AS REAL) > CAST(? AS REAL)", col),
"lt" => format!(" AND CAST(\"{}\" AS REAL) < CAST(? AS REAL)", col),
_ => unreachable!(),
}
}
_ => continue,
};
clauses.push_str(&clause);
}
(clauses, params)
}
/// Build an ORDER BY clause from sort rules.
/// Returns an empty string when there are no valid sort rules.
fn build_order_clause(sorts: &[crate::models::db_viewer::SortRule]) -> String {
let mut parts: Vec<String> = Vec::new();
for rule in sorts {
if !is_safe_identifier(&rule.column) {
continue;
}
let dir = match rule.order.as_str() {
"asc" | "ASC" => "ASC",
"desc" | "DESC" => "DESC",
_ => continue,
};
parts.push(format!("\"{}\" {}", rule.column, dir));
}
if parts.is_empty() {
String::new()
} else {
format!(" ORDER BY {}", parts.join(", "))
}
}
/// Build a parameterized UPDATE SQL statement.
///
/// The returned SQL uses `?` placeholders for both the SET values and the
@@ -350,6 +469,23 @@ pub fn parse_table_info_rows(rows: &[Vec<serde_json::Value>]) -> Vec<TableInfo>
/// Tries numeric/boolean types first (which need exact Rust type matching),
/// then UUID (with-uuid-1 feature), then chrono types (with-chrono-0_4),
/// then JSON/JSONB, then falls back to String.
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,
}
}
fn pg_value_to_json(row: &tokio_postgres::Row, i: usize) -> serde_json::Value {
// Integer types
if let Ok(Some(v)) = row.try_get::<_, Option<i32>>(i) {
@@ -393,7 +529,9 @@ fn pg_value_to_json(row: &tokio_postgres::Row, i: usize) -> serde_json::Value {
if let Ok(Some(v)) = row.try_get::<_, Option<serde_json::Value>>(i) {
return v;
}
// Text fallback
// Text fallback: catches varchar, text, char, and USER-DEFINED enum
// types. Under the simple query protocol, all values arrive as text
// and FromSql<String> converts them regardless of column type OID.
if let Ok(Some(v)) = row.try_get::<_, Option<String>>(i) {
return serde_json::Value::String(v);
}
@@ -581,22 +719,41 @@ pub async fn get_table_data(
table: String,
page: Option<i64>,
page_size: Option<i64>,
filters: Option<Vec<crate::models::db_viewer::FilterRule>>,
sorts: Option<Vec<crate::models::db_viewer::SortRule>>,
state: State<'_, crate::AppState>,
) -> Result<QueryResult, String> {
let p = page.unwrap_or(1);
let ps = page_size.unwrap_or(50);
let off = (p - 1) * ps;
let filters = filters.unwrap_or_default();
let sorts = sorts.unwrap_or_default();
let mut pm = state.pool_manager.lock().await;
match pm.get(&connection_id) {
Some(crate::db::pool::DbHandle::Postgresql(client, _)) => {
// Get total count
// Build filter clause (shared by COUNT and data queries)
let mut pg_param_idx: usize = 0;
let (filter_clause, filter_params) =
build_pg_filter_clause(&filters, &mut pg_param_idx);
let order_clause = build_order_clause(&sorts);
// Get total count (with filters applied)
let count_query =
format!("SELECT COUNT(*) FROM \"{}\".\"{}\"", schema, table);
let count_row = client
.query_one(&count_query, &[])
.await
.map_err(|e| e.to_string())?;
format!("SELECT COUNT(*) FROM \"{}\".\"{}\" WHERE 1=1{}", schema, table, filter_clause);
let count_row = if filter_params.is_empty() {
client
.query_one(&count_query, &[])
.await
.map_err(|e| e.to_string())?
} else {
let param_refs: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> =
filter_params.iter().map(|s| s as &(dyn tokio_postgres::types::ToSql + Sync)).collect();
client
.query_one(&count_query, &param_refs)
.await
.map_err(|e| e.to_string())?
};
let total_rows: i64 = count_row.get(0);
// Get column info with FK detection and enum type names
@@ -668,15 +825,50 @@ ORDER BY c.ordinal_position"#;
})
.collect();
// Get data
// Get data (with filters and sorts applied).
// Custom/enum types need explicit ::text cast because tokio-postgres
// FromSql<String> rejects custom type OIDs even in simple query mode.
let standard_pg_types: &[&str] = &[
"uuid", "text", "varchar", "char", "bpchar", "name",
"int2", "int4", "int8", "smallint", "integer", "bigint",
"float4", "float8", "real", "double precision",
"numeric", "decimal", "money",
"bool", "boolean",
"date", "time", "timetz", "timestamp", "timestamptz",
"interval", "json", "jsonb", "bytea", "oid",
"timestamp without time zone", "timestamp with time zone",
"time without time zone", "time with time zone",
];
let select_cols: Vec<String> = columns
.iter()
.map(|c| {
let lower = c.data_type.to_lowercase();
if standard_pg_types.contains(&lower.as_str()) {
format!("\"{}\"", c.name)
} else {
// Custom type (enum, composite, domain) — cast to text
format!("\"{}\"::text", c.name)
}
})
.collect();
let data_query = format!(
"SELECT * FROM \"{}\".\"{}\" LIMIT {} OFFSET {}",
schema, table, ps, off
"SELECT {} FROM \"{}\".\"{}\" WHERE 1=1{} {} LIMIT {} OFFSET {}",
select_cols.join(", "),
schema, table, filter_clause, order_clause, ps, off
);
let data_rows = client
.query(&data_query, &[])
.await
.map_err(|e| e.to_string())?;
let data_rows = if filter_params.is_empty() {
client
.query(&data_query, &[])
.await
.map_err(|e| e.to_string())?
} else {
let param_refs: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> =
filter_params.iter().map(|s| s as &(dyn tokio_postgres::types::ToSql + Sync)).collect();
client
.query(&data_query, &param_refs)
.await
.map_err(|e| e.to_string())?
};
let rows: Vec<Vec<serde_json::Value>> = data_rows
.iter()
.map(|row| (0..row.len()).map(|i| pg_value_to_json(row, i)).collect())
@@ -691,11 +883,20 @@ ORDER BY c.ordinal_position"#;
})
}
Some(crate::db::pool::DbHandle::Sqlite(conn)) => {
// Build filter clause (shared by COUNT and data queries)
let (filter_clause, filter_vals) = build_sqlite_filter_clause(&filters);
let order_clause = build_order_clause(&sorts);
let count_query =
format!("SELECT COUNT(*) FROM \"{}\".\"{}\"", schema, table);
let total_rows: i64 = conn
.query_row(&count_query, [], |r| r.get(0))
.map_err(|e| e.to_string())?;
format!("SELECT COUNT(*) FROM \"{}\".\"{}\" WHERE 1=1{}", schema, table, filter_clause);
let total_rows: i64 = if filter_vals.is_empty() {
conn.query_row(&count_query, [], |r| r.get(0))
.map_err(|e| e.to_string())?
} else {
let refs: Vec<&dyn rusqlite::types::ToSql> = filter_vals.iter().map(|v| v as &dyn rusqlite::types::ToSql).collect();
conn.query_row(&count_query, rusqlite::params_from_iter(&refs), |r| r.get(0))
.map_err(|e| e.to_string())?
};
// Get column metadata via PRAGMA table_info
let pragma_query = format!("PRAGMA table_info('{}')", table);
@@ -750,29 +951,38 @@ ORDER BY c.ordinal_position"#;
})
.collect();
// Get data
// Get data (with filters and sorts applied)
let data_query = format!(
"SELECT * FROM \"{}\".\"{}\" LIMIT {} OFFSET {}",
schema, table, ps, off
"SELECT * FROM \"{}\".\"{}\" WHERE 1=1{} {} LIMIT {} OFFSET {}",
schema, table, filter_clause, order_clause, ps, off
);
let mut stmt = conn.prepare(&data_query).map_err(|e| e.to_string())?;
let col_count = stmt.column_count();
let rows: Vec<Vec<serde_json::Value>> = stmt
.query_map([], |row| {
let rows: Vec<Vec<serde_json::Value>> = if filter_vals.is_empty() {
stmt.query_map([], |row| {
let mut vals = Vec::new();
for i in 0..col_count {
let val: Option<String> = row.get(i).unwrap_or(None);
vals.push(
val.map(serde_json::Value::String)
.unwrap_or(serde_json::Value::Null),
);
vals.push(sqlite_value_to_json(row, i));
}
Ok(vals)
})
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
.collect()
} else {
let refs: Vec<&dyn rusqlite::types::ToSql> = filter_vals.iter().map(|v| v as &dyn rusqlite::types::ToSql).collect();
stmt.query_map(rusqlite::params_from_iter(&refs), |row| {
let mut vals = Vec::new();
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(QueryResult {
columns,
@@ -844,7 +1054,7 @@ ORDER BY c.ordinal_position"#;
let col_rows = client
.query(col_query, &[&schema, &table])
.await
.map_err(|e| e.to_string())?;
.map_err(|e| pg_error_message(&e))?;
let columns: Vec<ColumnInfo> = col_rows
.iter()
.map(|r| {
@@ -875,7 +1085,7 @@ ORDER BY c.ordinal_position"#;
let data_rows = client
.query(&data_query, &[&value])
.await
.map_err(|e| e.to_string())?;
.map_err(|e| pg_error_message(&e))?;
let rows: Vec<Vec<serde_json::Value>> = data_rows
.iter()
.map(|row| (0..row.len()).map(|i| pg_value_to_json(row, i)).collect())
@@ -954,11 +1164,7 @@ ORDER BY c.ordinal_position"#;
.query_map([&value], |row| {
let mut vals = Vec::new();
for i in 0..col_count {
let val: Option<String> = row.get(i).unwrap_or(None);
vals.push(
val.map(serde_json::Value::String)
.unwrap_or(serde_json::Value::Null),
);
vals.push(sqlite_value_to_json(row, i));
}
Ok(vals)
})
@@ -1127,6 +1333,169 @@ pub async fn refresh_connection(
}
}
#[tauri::command]
pub async fn get_functions(
connection_id: String,
schema: Option<String>,
state: State<'_, crate::AppState>,
) -> Result<Vec<FunctionInfo>, String> {
let mut pm = state.pool_manager.lock().await;
match pm.get(&connection_id) {
Some(DbHandle::Postgresql(client, _)) => {
let schema = schema.unwrap_or_else(|| "public".to_string());
let query = crate::db::introspection::pg_functions_query(&schema);
let rows = client
.query(&query, &[&schema])
.await
.map_err(|e| e.to_string())?;
Ok(rows
.iter()
.map(|r| FunctionInfo {
name: r.get(0),
schema: r.get(1),
return_type: r.get::<_, Option<String>>(2).unwrap_or_default(),
argument_types: r.get::<_, Option<Vec<String>>>(3).unwrap_or_default(),
argument_names: r.get::<_, Option<Vec<String>>>(4).unwrap_or_default(),
argument_modes: r.get::<_, Option<Vec<String>>>(5).unwrap_or_default(),
language: r.get(6),
source: r.get(7),
kind: r.get(8),
})
.collect())
}
Some(DbHandle::Sqlite(_)) => Ok(vec![]),
None => Err("Connection not found".into()),
}
}
#[tauri::command]
pub async fn get_triggers(
connection_id: String,
schema: Option<String>,
state: State<'_, crate::AppState>,
) -> Result<Vec<TriggerInfo>, String> {
let mut pm = state.pool_manager.lock().await;
match pm.get(&connection_id) {
Some(DbHandle::Postgresql(client, _)) => {
let schema = schema.unwrap_or_else(|| "public".to_string());
let query = crate::db::introspection::pg_triggers_query(&schema);
let rows = client
.query(&query, &[&schema])
.await
.map_err(|e| e.to_string())?;
Ok(rows
.iter()
.map(|r| TriggerInfo {
name: r.get(0),
schema: r.get(1),
table_schema: r.get(2),
table_name: r.get(3),
event_manipulation: r.get(4),
action_timing: r.get(5),
action_orientation: r.get(6),
action_statement: r.get(7),
enabled: r.get(8),
})
.collect())
}
Some(DbHandle::Sqlite(_)) => Ok(vec![]),
None => Err("Connection not found".into()),
}
}
#[tauri::command]
pub async fn get_sequences(
connection_id: String,
schema: Option<String>,
state: State<'_, crate::AppState>,
) -> Result<Vec<SequenceInfo>, String> {
let mut pm = state.pool_manager.lock().await;
match pm.get(&connection_id) {
Some(DbHandle::Postgresql(client, _)) => {
let schema = schema.unwrap_or_else(|| "public".to_string());
let query = crate::db::introspection::pg_sequences_query(&schema);
let rows = client
.query(&query, &[&schema])
.await
.map_err(|e| e.to_string())?;
Ok(rows
.iter()
.map(|r| SequenceInfo {
name: r.get(0),
schema: r.get(1),
start_value: r.get::<_, Option<String>>(2).unwrap_or_default(),
min_value: r.get::<_, Option<String>>(3).unwrap_or_default(),
max_value: r.get::<_, Option<String>>(4).unwrap_or_default(),
increment: r.get::<_, Option<String>>(5).unwrap_or_default(),
current_value: r.get::<_, Option<String>>(6).unwrap_or_default(),
cycle: r.get::<_, Option<String>>(7)
.map(|s| s == "YES")
.unwrap_or(false),
})
.collect())
}
Some(DbHandle::Sqlite(_)) => Ok(vec![]),
None => Err("Connection not found".into()),
}
}
#[tauri::command]
pub async fn get_enums(
connection_id: String,
schema: Option<String>,
state: State<'_, crate::AppState>,
) -> Result<Vec<EnumInfo>, String> {
let mut pm = state.pool_manager.lock().await;
match pm.get(&connection_id) {
Some(DbHandle::Postgresql(client, _)) => {
let schema = schema.unwrap_or_else(|| "public".to_string());
let query = crate::db::introspection::pg_enums_query(&schema);
let rows = client
.query(&query, &[&schema])
.await
.map_err(|e| e.to_string())?;
Ok(rows
.iter()
.map(|r| EnumInfo {
name: r.get(0),
schema: r.get(1),
labels: r.get::<_, Option<Vec<String>>>(2).unwrap_or_default(),
})
.collect())
}
Some(DbHandle::Sqlite(_)) => Ok(vec![]),
None => Err("Connection not found".into()),
}
}
#[tauri::command]
pub async fn get_extensions(
connection_id: String,
state: State<'_, crate::AppState>,
) -> Result<Vec<ExtensionInfo>, String> {
let mut pm = state.pool_manager.lock().await;
match pm.get(&connection_id) {
Some(DbHandle::Postgresql(client, _)) => {
let query = crate::db::introspection::pg_extensions_query();
let rows = client
.query(&query, &[])
.await
.map_err(|e| e.to_string())?;
Ok(rows
.iter()
.map(|r| ExtensionInfo {
name: r.get(0),
schema: r.get(1),
version: r.get::<_, Option<String>>(2).unwrap_or_default(),
comment: r.get(3),
})
.collect())
}
Some(DbHandle::Sqlite(_)) => Ok(vec![]),
None => Err("Connection not found".into()),
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
+12
View File
@@ -27,6 +27,18 @@ pub fn get_connection_password(
.map_err(|e| e.to_string())
}
/// Retrieve a connection password from the OS keychain (internal helper).
/// Returns None if no password was stored for this connection.
pub fn get_connection_password_internal(
app: &tauri::AppHandle,
connection_id: &str,
) -> Result<Option<String>, String> {
app.keyring()
.store
.get_password(connection_id)
.map_err(|e| e.to_string())
}
/// Delete a connection password from the OS keychain.
#[tauri::command]
pub fn delete_connection_password(
+2 -1
View File
@@ -7,4 +7,5 @@ pub mod import_export;
pub mod test_connection;
pub mod ssh;
pub mod keychain;
pub mod demo;
pub mod demo;
pub mod backup;
+124 -1
View File
@@ -59,7 +59,7 @@ pub fn pg_columns_query(schema: &str, table: &str) -> String {
format!(
r#"SELECT
c.column_name,
c.data_type,
CASE WHEN c.data_type = 'USER-DEFINED' THEN c.udt_name ELSE c.data_type END AS data_type,
c.is_nullable,
c.character_maximum_length,
c.numeric_precision,
@@ -198,6 +198,93 @@ pub fn build_count_query(schema: &str, table: &str) -> String {
format!("SELECT COUNT(*) FROM \"{}\".\"{}\"", schema, table)
}
// ---------------------------------------------------------------------------
// Object introspection (functions, triggers, sequences, enums, extensions)
// ---------------------------------------------------------------------------
/// Query functions and procedures in a schema.
pub fn pg_functions_query(_schema: &str) -> String {
format!(
"SELECT p.proname, n.nspname, \
pg_catalog.format_type(p.prorettype, NULL) AS return_type, \
ARRAY(SELECT unnest(p.proargtypes::regtype[]::text[])) AS arg_types, \
ARRAY(SELECT unnest(p.proargnames::text[])) AS arg_names, \
ARRAY(SELECT unnest(p.proargmodes::text[])) AS arg_modes, \
l.lanname, pg_get_functiondef(p.oid) AS source, \
p.prokind::text \
FROM pg_proc p \
JOIN pg_namespace n ON p.pronamespace = n.oid \
JOIN pg_language l ON p.prolang = l.oid \
WHERE n.nspname = $1 \
AND p.prokind IN ('f', 'p') \
ORDER BY p.proname"
)
}
/// Query triggers in a schema.
pub fn pg_triggers_query(_schema: &str) -> String {
format!(
"SELECT t.tgname, tn.nspname AS trigger_schema, \
cn.nspname AS table_schema, c.relname AS table_name, \
CASE \
WHEN t.tgtype::int2 & 4 = 4 THEN 'INSERT' \
WHEN t.tgtype::int2 & 8 = 8 THEN 'DELETE' \
WHEN t.tgtype::int2 & 16 = 16 THEN 'UPDATE' \
WHEN t.tgtype::int2 & 32 = 32 THEN 'TRUNCATE' \
ELSE 'UNKNOWN' END AS event, \
CASE WHEN t.tgtype::int2 & 2 = 2 THEN 'BEFORE' ELSE 'AFTER' END AS timing, \
CASE WHEN t.tgtype::int2 & 1 = 1 THEN 'ROW' ELSE 'STATEMENT' END AS orientation, \
pg_get_triggerdef(t.oid) AS definition, \
t.tgenabled::text \
FROM pg_trigger t \
JOIN pg_class c ON t.tgrelid = c.oid \
JOIN pg_namespace cn ON c.relnamespace = cn.oid \
CROSS JOIN LATERAL (SELECT nspname FROM pg_namespace WHERE oid = (SELECT pronamespace FROM pg_proc WHERE oid = t.tgfoid)) tn \
WHERE cn.nspname = $1 AND NOT t.tgisinternal \
ORDER BY t.tgname"
)
}
/// Query sequences in a schema via information_schema.
pub fn pg_sequences_query(schema: &str) -> String {
format!(
"SELECT sequence_name, '{}' AS schema, \
COALESCE(start_value::text, '1'), \
COALESCE(minimum_value::text, '1'), \
COALESCE(maximum_value::text, '9223372036854775807'), \
COALESCE(increment::text, '1'), \
COALESCE(pg_catalog.pg_sequence_last_value(sequence_name::regclass)::text, '0'), \
COALESCE(cycle_option::text, 'NO') \
FROM information_schema.sequences \
WHERE sequence_schema = $1 \
ORDER BY sequence_name",
schema
)
}
/// Query enums in a schema.
pub fn pg_enums_query(_schema: &str) -> String {
format!(
"SELECT t.typname, n.nspname, \
ARRAY(SELECT e.enumlabel FROM pg_enum e \
WHERE e.enumtypid = t.oid ORDER BY e.enumsortorder) AS labels \
FROM pg_type t \
JOIN pg_namespace n ON t.typnamespace = n.oid \
WHERE t.typtype = 'e' AND n.nspname = $1 \
ORDER BY t.typname"
)
}
/// Query installed extensions.
pub fn pg_extensions_query() -> String {
"SELECT e.extname, n.nspname, e.extversion::text, \
pg_catalog.obj_description(e.oid, 'pg_extension') AS comment \
FROM pg_extension e \
JOIN pg_namespace n ON e.extnamespace = n.oid \
ORDER BY e.extname"
.to_string()
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -382,4 +469,40 @@ mod tests {
assert!(sql.contains("public"), "should contain schema name");
assert!(sql.contains("orders"), "should contain table name");
}
// ---------------------------------------------------------------
// Object introspection
// ---------------------------------------------------------------
#[test]
fn pg_functions_query_has_expected_columns() {
let sql = pg_functions_query("public");
assert!(sql.contains("pg_proc"));
assert!(sql.contains("proname"));
}
#[test]
fn pg_triggers_query_has_expected_columns() {
let sql = pg_triggers_query("public");
assert!(sql.contains("pg_trigger"));
assert!(sql.contains("tgname"));
}
#[test]
fn pg_sequences_query_filters_by_schema() {
let sql = pg_sequences_query("myschema");
assert!(sql.contains("myschema"));
}
#[test]
fn pg_enums_query_has_typtype_e() {
let sql = pg_enums_query("public");
assert!(sql.contains("typtype = 'e'"));
}
#[test]
fn pg_extensions_query_selects_from_pg_extension() {
let sql = pg_extensions_query();
assert!(sql.contains("pg_extension"));
}
}
+11 -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};
use commands::{connections, db_viewer, folders, tags, settings, import_export, keychain, demo, backup};
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
#[tauri::command]
@@ -35,6 +35,7 @@ pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_keyring_store::init())
.manage(AppState {
db_store: store_ref,
@@ -80,10 +81,19 @@ pub fn run() {
db_viewer::get_fk_preview,
db_viewer::execute_change,
db_viewer::refresh_connection,
db_viewer::get_functions,
db_viewer::get_triggers,
db_viewer::get_sequences,
db_viewer::get_enums,
db_viewer::get_extensions,
keychain::save_connection_password,
keychain::get_connection_password,
keychain::delete_connection_password,
demo::recreate_demo_db,
backup::detect_pg_tools,
backup::pg_dump,
backup::pg_restore,
backup::db_sync,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+100
View File
@@ -0,0 +1,100 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BackupOptions {
pub format: String, // "plain" | "custom" | "tar" | "directory"
pub file_path: String,
pub schema: Option<String>,
pub tables: Option<Vec<String>>,
pub no_owner: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RestoreOptions {
pub format: String,
pub file_path: String,
pub clean: bool,
pub schema: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SyncOptions {
pub source_connection_id: String,
pub target_connection_id: String,
pub schema: Option<String>,
pub tables: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PgToolStatus {
pub pg_dump_found: bool,
pub pg_restore_found: bool,
pub pg_dump_version: Option<String>,
pub pg_restore_version: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackupJob {
pub id: String,
pub connection_id: String,
pub r#type: String, // "dump" | "restore" | "sync"
pub format: Option<String>,
pub file_path: Option<String>,
pub source_connection_id: Option<String>,
pub status: String,
pub error_message: Option<String>,
pub size_bytes: Option<i64>,
pub started_at: String,
pub completed_at: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct BackupProgressEvent {
pub job_id: String,
pub status: String,
pub progress: Option<f64>,
pub output_line: Option<String>,
pub error: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn backup_options_serialization() {
let opts = BackupOptions {
format: "plain".into(),
file_path: "/tmp/dump.sql".into(),
schema: Some("public".into()),
tables: None,
no_owner: true,
};
let json = serde_json::to_string(&opts).unwrap();
assert!(json.contains("plain"));
assert!(json.contains("noOwner"));
}
#[test]
fn backup_job_serialization() {
let job = BackupJob {
id: "job-1".into(),
connection_id: "conn-1".into(),
r#type: "dump".into(),
format: Some("plain".into()),
file_path: Some("/tmp/dump.sql".into()),
source_connection_id: None,
status: "completed".into(),
error_message: None,
size_bytes: Some(1024),
started_at: "2025-07-28T10:00:00Z".into(),
completed_at: Some("2025-07-28T10:01:00Z".into()),
};
let json = serde_json::to_string(&job).unwrap();
assert!(json.contains("dump"));
assert!(json.contains("completed"));
}
}
+144
View File
@@ -1,5 +1,22 @@
use serde::{Deserialize, Serialize};
/// A single filter rule sent from the frontend.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FilterRule {
pub id: String,
pub column: String,
pub operator: String, // "eq" | "neq" | "contains" | "starts" | "ends" | "gt" | "lt" | "null" | "notnull"
pub value: String,
}
/// A single sort rule sent from the frontend.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SortRule {
pub id: String,
pub column: String,
pub order: String, // "asc" | "desc"
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TableInfo {
pub name: String,
@@ -34,6 +51,59 @@ pub struct Pagination {
pub total_rows: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FunctionInfo {
pub name: String,
pub schema: String,
pub return_type: String,
pub argument_types: Vec<String>,
pub argument_names: Vec<String>,
pub argument_modes: Vec<String>,
pub language: String,
pub source: Option<String>,
pub kind: String, // 'f' = function, 'p' = procedure
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TriggerInfo {
pub name: String,
pub schema: String,
pub table_schema: String,
pub table_name: String,
pub event_manipulation: String,
pub action_timing: String,
pub action_orientation: String,
pub action_statement: String,
pub enabled: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SequenceInfo {
pub name: String,
pub schema: String,
pub start_value: String,
pub min_value: String,
pub max_value: String,
pub increment: String,
pub current_value: String,
pub cycle: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnumInfo {
pub name: String,
pub schema: String,
pub labels: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtensionInfo {
pub name: String,
pub schema: String,
pub version: String,
pub comment: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Change {
@@ -191,4 +261,78 @@ mod tests {
assert!(json.contains(r#""page_size":50"#));
assert!(json.contains(r#""total_rows":250"#));
}
#[test]
fn function_info_serialization() {
let info = FunctionInfo {
name: "get_user".into(),
schema: "public".into(),
return_type: "TABLE(id integer, name text)".into(),
argument_types: vec!["integer".into()],
argument_names: vec!["p_id".into()],
argument_modes: vec!["IN".into()],
language: "plpgsql".into(),
source: Some("BEGIN RETURN; END;".into()),
kind: "f".into(),
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("get_user"));
assert!(json.contains("plpgsql"));
}
#[test]
fn trigger_info_serialization() {
let info = TriggerInfo {
name: "trg_audit".into(),
schema: "public".into(),
table_schema: "public".into(),
table_name: "users".into(),
event_manipulation: "INSERT".into(),
action_timing: "AFTER".into(),
action_orientation: "ROW".into(),
action_statement: "EXECUTE FUNCTION audit_log()".into(),
enabled: "O".into(),
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("trg_audit"));
}
#[test]
fn sequence_info_serialization() {
let info = SequenceInfo {
name: "users_id_seq".into(),
schema: "public".into(),
start_value: "1".into(),
min_value: "1".into(),
max_value: "9223372036854775807".into(),
increment: "1".into(),
current_value: "42".into(),
cycle: false,
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("users_id_seq"));
}
#[test]
fn enum_info_serialization() {
let info = EnumInfo {
name: "user_role".into(),
schema: "public".into(),
labels: vec!["admin".into(), "editor".into(), "viewer".into()],
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("admin"));
}
#[test]
fn extension_info_serialization() {
let info = ExtensionInfo {
name: "pg_stat_statements".into(),
schema: "public".into(),
version: "1.10".into(),
comment: Some("track SQL statistics".into()),
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("pg_stat_statements"));
}
}
+2 -1
View File
@@ -1,3 +1,4 @@
pub mod backup;
pub mod connection;
pub mod db_viewer;
pub mod folder;
@@ -6,7 +7,7 @@ pub mod settings;
pub use connection::{Connection, ConnectionInput};
#[allow(unused_imports)]
pub use db_viewer::{Change, ColumnInfo, Pagination, QueryResult, TableInfo};
pub use db_viewer::{Change, ColumnInfo, FilterRule, Pagination, QueryResult, SortRule, TableInfo};
pub use folder::{Folder, FolderInput};
pub use settings::Settings;
pub use tag::{Tag, TagInput};
+37 -1
View File
@@ -143,6 +143,32 @@ pub fn run_migrations(conn: &Connection) -> Result<(), String> {
.map_err(|e| e.to_string())?;
}
// v4: backup_history
if current_ver < 4 {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS backup_history (
id TEXT PRIMARY KEY,
connection_id TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('dump', 'restore', 'sync')),
format TEXT,
file_path TEXT,
source_connection_id TEXT,
status TEXT NOT NULL DEFAULT 'running'
CHECK(status IN ('running', 'completed', 'failed', 'cancelled')),
error_message TEXT,
size_bytes INTEGER,
started_at TEXT NOT NULL,
completed_at TEXT
);"
).map_err(|e| e.to_string())?;
conn.execute(
"INSERT INTO schema_version (version) VALUES (4)",
[],
)
.map_err(|e| e.to_string())?;
}
Ok(())
}
@@ -175,6 +201,16 @@ mod tests {
assert!(tables.contains(&"schema_version".to_string()));
}
#[test]
fn v4_creates_backup_history_table() {
let conn = Connection::open_in_memory().unwrap();
run_migrations(&conn).unwrap();
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM backup_history", [], |r| r.get(0))
.unwrap();
assert_eq!(count, 0);
}
#[test]
fn migrations_are_idempotent() {
let conn = fresh_db();
@@ -183,6 +219,6 @@ mod tests {
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM schema_version", [], |row| row.get(0))
.unwrap();
assert_eq!(count, 2);
assert_eq!(count, 3);
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Gridline",
"version": "0.1.0",
"version": "0.2.0",
"identifier": "com.adrianbonpin.gridline",
"build": {
"beforeDevCommand": "bun run dev",
+3
View File
@@ -2,6 +2,7 @@ import { useEffect } from "react";
import { useConnectionStore } from "./stores/connectionStore";
import { useSettingsStore } from "./stores/settingsStore";
import { useUiStore } from "./stores/uiStore";
import { useBackupStore } from "./stores/backupStore";
import { HomeScreen } from "./components/layout/HomeScreen";
import { SettingsPage } from "./components/settings/SettingsPage";
import { NewConnectionScreen } from "./components/connections/NewConnectionScreen";
@@ -32,6 +33,8 @@ export default function App() {
useEffect(() => {
loadConnections();
loadSettings();
// Init backup event listener (noop outside Tauri)
useBackupStore.getState().initListener().catch(() => {});
}, [loadConnections, loadSettings]);
useEffect(() => {
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { DndContext } from "@dnd-kit/core";
import { ConnectionCard } from "./ConnectionCard";
import type { Connection, Tag } from "../../lib/types";
import { useUiStore } from "../../stores/uiStore";
@@ -15,42 +16,50 @@ const conn: Connection = {
tag_ids: ["t1", "t2"], created_at: "", updated_at: "",
};
function Wrapper({ children }: { children: React.ReactNode }) {
return <DndContext>{children}</DndContext>;
}
describe("ConnectionCard", () => {
beforeEach(() => {
useUiStore.setState({ selectedItemIds: [] });
});
it("renders name and host", () => {
render(<ConnectionCard connection={conn} tags={tags} />);
render(<ConnectionCard connection={conn} tags={tags} />, { wrapper: Wrapper });
expect(screen.getByText("Prod DB")).toBeInTheDocument();
expect(screen.getByText("prod.example.com:5432")).toBeInTheDocument();
});
it("renders db type label", () => {
render(<ConnectionCard connection={conn} tags={tags} />);
render(<ConnectionCard connection={conn} tags={tags} />, { wrapper: Wrapper });
expect(screen.getByText(/postgresql/i)).toBeInTheDocument();
});
it("renders tag badges", () => {
render(<ConnectionCard connection={conn} tags={tags} />);
render(<ConnectionCard connection={conn} tags={tags} />, { wrapper: Wrapper });
expect(screen.getByText("production")).toBeInTheDocument();
expect(screen.getByText("primary")).toBeInTheDocument();
});
it("renders drag handle", () => {
render(<ConnectionCard connection={conn} tags={tags} />, { wrapper: Wrapper });
expect(screen.getByLabelText("Drag to move connection")).toBeInTheDocument();
});
it("omits port for sqlite", () => {
const sqlite = { ...conn, db_type: "sqlite" as const, host: "/data/x.db", port: null };
render(<ConnectionCard connection={sqlite} tags={tags} />);
render(<ConnectionCard connection={sqlite} tags={tags} />, { wrapper: Wrapper });
expect(screen.getByText("/data/x.db")).toBeInTheDocument();
expect(screen.queryByText(/:5432/)).not.toBeInTheDocument();
});
it("fires onTagToggle when a tag badge is clicked", async () => {
const user = userEvent.setup();
const fn = vi.fn();
render(<ConnectionCard connection={conn} tags={tags} onTagToggle={fn} />);
render(<ConnectionCard connection={conn} tags={tags} onTagToggle={fn} />, { wrapper: Wrapper });
await user.click(screen.getByText("production"));
expect(fn).toHaveBeenCalledWith("t1");
});
it("opens DbViewer on single click when nothing is selected", async () => {
const user = userEvent.setup();
const fn = vi.fn();
render(<ConnectionCard connection={conn} tags={tags} onOpenDbViewer={fn} />);
render(<ConnectionCard connection={conn} tags={tags} onOpenDbViewer={fn} />, { wrapper: Wrapper });
await user.click(screen.getByText("Prod DB"));
expect(fn).toHaveBeenCalledWith(conn.id);
});
@@ -59,7 +68,7 @@ describe("ConnectionCard", () => {
const user = userEvent.setup();
useUiStore.setState({ selectedItemIds: ["other-id"] });
const fn = vi.fn();
render(<ConnectionCard connection={conn} tags={tags} onOpenDbViewer={fn} />);
render(<ConnectionCard connection={conn} tags={tags} onOpenDbViewer={fn} />, { wrapper: Wrapper });
await user.click(screen.getByText("Prod DB"));
// Should NOT open — should toggle selection instead
expect(fn).not.toHaveBeenCalled();
+38 -11
View File
@@ -3,8 +3,10 @@ import type { Connection, Tag } from "../../lib/types";
import { DB_ICONS, DB_LABELS } from "../../lib/dbIcons";
import { ENV_LABELS, ENV_COLORS } from "../../lib/environment";
import { TagBadge } from "../tags/TagBadge";
import { Check } from "lucide-react";
import { Check, GripVertical } from "lucide-react";
import { useUiStore } from "../../stores/uiStore";
import { useDraggable } from "@dnd-kit/core";
import { CSS } from "@dnd-kit/utilities";
interface ConnectionCardProps {
connection: Connection;
@@ -21,6 +23,18 @@ function ConnectionCardBase({
}: ConnectionCardProps) {
const selectedItemIds = useUiStore((s) => s.selectedItemIds);
const toggleItemSelection = useUiStore((s) => s.toggleItemSelection);
const { attributes, listeners, setNodeRef, transform, isDragging } =
useDraggable({
id: connection.id,
data: { type: "connection", connection },
});
const style: React.CSSProperties = {
transform: CSS.Translate.toString(transform),
opacity: isDragging ? 0.5 : 1,
cursor: isDragging ? "grabbing" : "default",
};
const tagMap = new Map(tags.map((t) => [t.id, t]));
const cardTags = connection.tag_ids
.map((id) => tagMap.get(id))
@@ -42,6 +56,8 @@ function ConnectionCardBase({
return (
<div
ref={setNodeRef}
style={style}
onClick={handleClick}
className={`relative group rounded-xl border transition-colors cursor-pointer ${
isSelected
@@ -49,6 +65,14 @@ function ConnectionCardBase({
: "bg-surface border-border hover:border-border-hover"
}`}
>
<div
{...listeners}
{...attributes}
className="absolute top-1/2 -translate-y-1/2 right-2 opacity-0 group-hover:opacity-100 transition-opacity cursor-grab z-10"
aria-label="Drag to move connection"
>
<GripVertical size={14} className="text-text-muted" />
</div>
<div className="p-4">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-lg bg-surface-raised border border-border flex items-center justify-center text-xl">
@@ -58,18 +82,21 @@ function ConnectionCardBase({
<div className="font-semibold truncate text-text">
{connection.name}
</div>
<div className="text-xs text-text-muted">
{DB_LABELS[connection.db_type] ??
connection.db_type}
<div className="flex items-center gap-2">
<span className="text-xs text-text-muted">
{DB_LABELS[connection.db_type] ??
connection.db_type}
</span>
{connection.environment && (
<span
className={`shrink-0 rounded-full border px-2 py-0.5 text-[11px] font-medium leading-none ${ENV_COLORS[connection.environment] ?? "bg-surface-raised border-border text-text-muted"}`}
>
{ENV_LABELS[connection.environment] ??
connection.environment}
</span>
)}
</div>
</div>
{connection.environment && (
<span
className={`shrink-0 rounded-full border px-2 py-0.5 text-[11px] font-medium leading-none ${ENV_COLORS[connection.environment] ?? "bg-surface-raised border-border text-text-muted"}`}
>
{ENV_LABELS[connection.environment] ?? connection.environment}
</span>
)}
</div>
<div className="text-xs text-text-muted mb-2 font-mono truncate">
{hostLabel}
+101 -64
View File
@@ -1,11 +1,102 @@
import type { Connection, Folder, Tag } from "../../lib/types";
import { Folder as FolderIcon, Check, Pencil, Trash2 } from "lucide-react";
import { useDroppable } from "@dnd-kit/core";
import { ConnectionCard } from "./ConnectionCard";
import { FolderBreadcrumb } from "../folders/FolderBreadcrumb";
import { getChildFolders } from "../../lib/utils";
import { useUiStore } from "../../stores/uiStore";
import { TagBadge } from "../tags/TagBadge";
interface DroppableFolderCardProps {
folder: Folder;
isSelected: boolean;
count: number;
subfolderCount: number;
folderTags: Tag[];
onFolderClick: (id: string) => void;
onToggleSelection: (id: string) => void;
}
function DroppableFolderCard({
folder,
isSelected,
count,
subfolderCount,
folderTags,
onFolderClick,
onToggleSelection,
}: DroppableFolderCardProps) {
const { setNodeRef, isOver } = useDroppable({
id: `folder-${folder.id}`,
data: { type: "folder", folder },
});
return (
<div
ref={setNodeRef}
className={`relative group rounded-xl border transition-colors ${
isSelected
? "bg-accent/10 border-accent"
: "bg-surface border-border hover:border-border-hover"
} ${isOver ? "ring-1 ring-accent bg-accent/10" : ""}`}
>
<button
onClick={() => onFolderClick(folder.id)}
className="w-full p-3 text-left min-w-0 cursor-pointer"
>
<div className="flex items-center gap-2">
<FolderIcon
size={18}
className={
isSelected
? "text-accent"
: "text-text-muted"
}
/>
<span className="font-semibold text-sm truncate text-text">
{folder.name}
</span>
</div>
<div className="text-xs text-text-muted mt-1">
{count > 0 &&
`${count} item${count !== 1 ? "s" : ""}`}
{count > 0 && subfolderCount > 0 && " · "}
{subfolderCount > 0 &&
`${subfolderCount} subfolder${subfolderCount !== 1 ? "s" : ""}`}
{count === 0 &&
subfolderCount === 0 &&
"Empty folder"}
</div>
{folderTags.length > 0 && (
<div className="flex gap-1 flex-wrap mt-2">
{folderTags.map((t) => (
<TagBadge key={t.id} tag={t} />
))}
</div>
)}
</button>
<button
onClick={(e) => {
e.stopPropagation();
onToggleSelection(folder.id);
}}
className={`absolute -top-1.5 -left-1.5 w-4 h-4 rounded border flex items-center justify-center transition-all ${
isSelected
? "bg-accent border-accent opacity-100"
: "border-border bg-surface opacity-0 group-hover:opacity-100"
}`}
>
{isSelected && (
<Check
size={12}
className="text-white"
/>
)}
</button>
</div>
);
}
interface ConnectionGridProps {
connections: Connection[];
tags: Tag[];
@@ -118,72 +209,18 @@ export function ConnectionGrid({
const tagMap = new Map(tags.map((t) => [t.id, t]));
const folderTags = f.tag_ids
.map((id) => tagMap.get(id))
.filter(Boolean) as import("../../lib/types").Tag[];
.filter(Boolean) as Tag[];
return (
<div
<DroppableFolderCard
key={f.id}
className={`relative group rounded-xl border transition-colors ${
isSelected
? "bg-accent/10 border-accent"
: "bg-surface border-border hover:border-border-hover"
}`}
>
<button
onClick={() => handleFolderClick(f.id)}
className="w-full p-3 text-left min-w-0 cursor-pointer"
>
<div className="flex items-center gap-2">
<FolderIcon
size={18}
className={
isSelected
? "text-accent"
: "text-text-muted"
}
/>
<span className="font-semibold text-sm truncate text-text">
{f.name}
</span>
</div>
<div className="text-xs text-text-muted mt-1">
{count > 0 &&
`${count} item${count !== 1 ? "s" : ""}`}
{count > 0 &&
subfolderCount > 0 &&
" · "}
{subfolderCount > 0 &&
`${subfolderCount} subfolder${subfolderCount !== 1 ? "s" : ""}`}
{count === 0 &&
subfolderCount === 0 &&
"Empty folder"}
</div>
{folderTags.length > 0 && (
<div className="flex gap-1 flex-wrap mt-2">
{folderTags.map((t) => (
<TagBadge key={t.id} tag={t} />
))}
</div>
)}
</button>
<button
onClick={(e) => {
e.stopPropagation();
toggleItemSelection(f.id);
}}
className={`absolute -top-1.5 -left-1.5 w-4 h-4 rounded border flex items-center justify-center transition-all ${
isSelected
? "bg-accent border-accent opacity-100"
: "border-border bg-surface opacity-0 group-hover:opacity-100"
}`}
>
{isSelected && (
<Check
size={12}
className="text-white"
/>
)}
</button>
</div>
folder={f}
isSelected={isSelected}
count={count}
subfolderCount={subfolderCount}
folderTags={folderTags}
onFolderClick={handleFolderClick}
onToggleSelection={toggleItemSelection}
/>
);
})}
{directConnections.map((c) => (
+222
View File
@@ -0,0 +1,222 @@
import { useState, useEffect, useCallback } from "react";
import { AnimatedModal } from "../ui/AnimatedModal";
import { Button } from "../ui/Button";
import { Select } from "../ui/Select";
import { BackupProgress } from "./BackupProgress";
import { useBackupStore } from "../../stores/backupStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { detectPgTools, pgDump } from "../../lib/commands";
import type { PgToolStatus } from "../../lib/types";
interface BackupDialogProps {
open: boolean;
connectionId: string;
onClose: () => void;
}
type BackupFormat = "plain" | "custom" | "tar" | "directory";
const FORMAT_OPTIONS = [
{ value: "plain", label: "Plain SQL" },
{ value: "custom", label: "Custom Archive" },
{ value: "tar", label: "Tarball" },
{ value: "directory", label: "Directory" },
];
const PLATFORM_INSTALL_INSTRUCTIONS: Record<string, string> = {
darwin: "brew install libpq",
linux: "sudo apt install postgresql-client # Debian/Ubuntu\nsudo dnf install postgresql # Fedora\nsudo pacman -S postgresql # Arch",
win32: "Download PostgreSQL installer from https://www.postgresql.org/download/windows/ and ensure pg_dump is in your PATH.",
};
function getPlatformInstructions(): string {
const platform = typeof navigator !== "undefined" ? navigator.platform.toLowerCase() : "";
if (platform.includes("mac") || platform.includes("darwin")) return PLATFORM_INSTALL_INSTRUCTIONS.darwin;
if (platform.includes("linux")) return PLATFORM_INSTALL_INSTRUCTIONS.linux;
if (platform.includes("win")) return PLATFORM_INSTALL_INSTRUCTIONS.win32;
return PLATFORM_INSTALL_INSTRUCTIONS.linux;
}
export function BackupDialog({ open, connectionId, onClose }: BackupDialogProps) {
const [format, setFormat] = useState<BackupFormat>("custom");
const [filePath, setFilePath] = useState("");
const [schema, setSchema] = useState("");
const [noOwner, setNoOwner] = useState(true);
const [toolStatus, setToolStatus] = useState<PgToolStatus | null>(null);
const [checkingTools, setCheckingTools] = useState(false);
const [running, setRunning] = useState(false);
const activeJobId = useBackupStore((s) => s.activeJobId);
const jobs = useBackupStore((s) => s.jobs);
const startJob = useBackupStore((s) => s.startJob);
const notify = useNotificationStore((s) => s.notify);
const activeJob = jobs.find((j) => j.id === activeJobId);
useEffect(() => {
if (!open) return;
setCheckingTools(true);
detectPgTools()
.then((status) => setToolStatus(status))
.catch(() => setToolStatus({ pg_dump_found: false, pg_restore_found: false, pg_dump_version: null, pg_restore_version: null }))
.finally(() => setCheckingTools(false));
}, [open]);
const handlePickFile = useCallback(async () => {
try {
const { save } = await import("@tauri-apps/plugin-dialog");
const extensions: Record<BackupFormat, string[]> = {
plain: ["sql"],
custom: ["dump", "custom"],
tar: ["tar"],
directory: [],
};
const picked = await save({
defaultPath: `backup.${format === "custom" ? "dump" : format === "plain" ? "sql" : "tar"}`,
filters: [{ name: "Backup", extensions: extensions[format] }],
});
if (picked) setFilePath(picked);
} catch {
// dialog not available (non-Tauri env), use manual path input
}
}, [format]);
const handleStartBackup = useCallback(async () => {
if (!filePath) {
notify("Please select a file path", "error");
return;
}
setRunning(true);
const jobId = `dump-${Date.now()}`;
startJob(jobId, "dump");
try {
await pgDump(connectionId, {
format,
filePath,
schema: schema || undefined,
tables: undefined,
noOwner,
});
notify("Backup completed successfully", "success");
onClose();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
notify(`Backup failed: ${parseError(msg)}`, "error");
} finally {
setRunning(false);
}
}, [filePath, format, schema, noOwner, connectionId, startJob, notify, onClose]);
const toolsMissing = toolStatus && !toolStatus.pg_dump_found;
return (
<AnimatedModal open={open} onClose={onClose}>
<div className="w-full min-w-md max-w-lg max-h-[80vh] overflow-y-auto">
<h3 className="font-heading text-text text-lg mb-4">Backup Database</h3>
{checkingTools && (
<p className="text-sm text-text-muted mb-4">Checking for pg_dump...</p>
)}
{toolsMissing && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-md px-4 py-3 mb-4 space-y-2">
<p className="text-amber-300 text-sm font-medium">pg_dump not found</p>
<p className="text-amber-200/80 text-xs">
The PostgreSQL client tools are required for backup/restore operations. Install them using:
</p>
<pre className="text-xs text-amber-100 bg-amber-500/10 rounded p-2 whitespace-pre-wrap">
{getPlatformInstructions()}
</pre>
</div>
)}
{!checkingTools && !toolsMissing && (
<div className="space-y-4">
{/* Format selector */}
<div className="space-y-1">
<label className="text-xs text-text-muted">Format</label>
<Select
value={format}
onChange={(v) => setFormat(v as BackupFormat)}
options={FORMAT_OPTIONS}
/>
</div>
{/* File path */}
<div className="space-y-1">
<label className="text-xs text-text-muted">Output File</label>
<div className="flex gap-2">
<input
type="text"
value={filePath}
onChange={(e) => setFilePath(e.target.value)}
placeholder="/path/to/backup.dump"
className="flex-1 rounded-full bg-surface border border-border px-4 py-2 text-sm text-text placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors"
/>
<Button variant="secondary" onClick={handlePickFile}>
Browse
</Button>
</div>
</div>
{/* Schema filter */}
<div className="space-y-1">
<label className="text-xs text-text-muted">Schema (optional)</label>
<input
type="text"
value={schema}
onChange={(e) => setSchema(e.target.value)}
placeholder="public"
className="w-full rounded-full bg-surface border border-border px-4 py-2 text-sm text-text placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors"
/>
</div>
{/* No owner toggle */}
<label className="flex items-center gap-2 text-sm text-text-muted cursor-pointer">
<input
type="checkbox"
checked={noOwner}
onChange={(e) => setNoOwner(e.target.checked)}
className="rounded bg-surface border-border accent-accent"
/>
No Owner (--no-owner flag)
</label>
{/* Progress */}
{activeJob?.status === "running" && (
<BackupProgress
progress={50}
jobType="dump"
status="running"
/>
)}
{/* Actions */}
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={onClose} disabled={running}>
Cancel
</Button>
<Button onClick={handleStartBackup} disabled={running || !filePath}>
{running ? "Backing up..." : "Start Backup"}
</Button>
</div>
</div>
)}
</div>
</AnimatedModal>
);
}
function parseError(msg: string): string {
if (msg.includes("pg_dump:")) {
const [, ...rest] = msg.split("pg_dump:");
return rest.join(":").trim() || msg;
}
if (msg.includes("No such file or directory")) {
return `File not found. Check the output path and try again.`;
}
if (msg.includes("Permission denied")) {
return `Permission denied. Check file permissions for the output path.`;
}
return msg;
}
+305
View File
@@ -0,0 +1,305 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { Download, FolderOpen, HardDrive } from "lucide-react";
import { save } from "@tauri-apps/plugin-dialog";
import { Button } from "../ui/Button";
import { BackupProgress } from "./BackupProgress";
import { useBackupStore } from "../../stores/backupStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { detectPgTools, pgDump, getSchemas } from "../../lib/commands";
import type { PgToolStatus } from "../../lib/types";
interface BackupPageProps {
connectionId: string;
}
type BackupFormat = "plain" | "custom" | "tar" | "directory";
const PLATFORM_INSTALL_INSTRUCTIONS: Record<string, string> = {
darwin: "brew install libpq",
linux: "sudo apt install postgresql-client # Debian/Ubuntu\nsudo dnf install postgresql # Fedora\nsudo pacman -S postgresql # Arch",
win32: "Download PostgreSQL installer from https://www.postgresql.org/download/windows/ and ensure pg_dump is in your PATH.",
};
function getPlatformInstructions(): string {
const platform =
typeof navigator !== "undefined"
? navigator.platform.toLowerCase()
: "";
if (platform.includes("mac") || platform.includes("darwin"))
return PLATFORM_INSTALL_INSTRUCTIONS.darwin;
if (platform.includes("linux")) return PLATFORM_INSTALL_INSTRUCTIONS.linux;
if (platform.includes("win")) return PLATFORM_INSTALL_INSTRUCTIONS.win32;
return PLATFORM_INSTALL_INSTRUCTIONS.linux;
}
export function BackupPage({ connectionId }: BackupPageProps) {
const [format, setFormat] = useState<BackupFormat>("custom");
const [filePath, setFilePath] = useState("");
const [schema, setSchema] = useState("");
const [noOwner, setNoOwner] = useState(true);
const [toolStatus, setToolStatus] = useState<PgToolStatus | null>(null);
const [checkingTools, setCheckingTools] = useState(true);
const [availableSchemas, setAvailableSchemas] = useState<string[]>([]);
const activeJobId = useBackupStore((s) => s.activeJobId);
const jobs = useBackupStore((s) => s.jobs);
const startJob = useBackupStore((s) => s.startJob);
const notify = useNotificationStore((s) => s.notify);
const activeJob = jobs.find((j) => j.id === activeJobId);
const isRunning = activeJob?.status === "running";
// Track our job ID so we only react to jobs we started
const pendingJobRef = useRef<string | null>(null);
// React to job completion/failure via store events
useEffect(() => {
if (!pendingJobRef.current || !activeJob) return;
if (activeJob.id !== pendingJobRef.current) return;
if (activeJob.status === "completed") {
notify("Backup completed successfully", "success");
pendingJobRef.current = null;
} else if (activeJob.status === "failed") {
notify(
`Backup failed: ${activeJob.error_message || "Unknown error"}`,
"error",
);
pendingJobRef.current = null;
}
}, [activeJob, notify]);
useEffect(() => {
setCheckingTools(true);
detectPgTools()
.then((status) => setToolStatus(status))
.catch(() =>
setToolStatus({
pg_dump_found: false,
pg_restore_found: false,
pg_dump_version: null,
pg_restore_version: null,
}),
)
.finally(() => setCheckingTools(false));
getSchemas(connectionId)
.then((schemas) => setAvailableSchemas(schemas))
.catch(() => setAvailableSchemas([]));
}, [connectionId]);
const handlePickFile = useCallback(async () => {
const extensions: Record<BackupFormat, string[]> = {
plain: ["sql"],
custom: ["dump", "custom"],
tar: ["tar"],
directory: [],
};
const picked = await save({
defaultPath: `backup.${
format === "custom"
? "dump"
: format === "plain"
? "sql"
: "tar"
}`,
filters: [{ name: "Backup", extensions: extensions[format] }],
});
if (picked) setFilePath(picked);
}, [format]);
const handleStartBackup = useCallback(async () => {
if (!filePath) {
notify("Please select a file path", "error");
return;
}
const jobId = `dump-${Date.now()}`;
startJob(jobId, "dump");
pendingJobRef.current = jobId;
try {
// pgDump returns the job ID immediately — completion
// comes via Tauri events handled by the backupStore
await pgDump(connectionId, {
format,
filePath,
schema: schema || undefined,
tables: undefined,
noOwner,
});
} catch (e) {
// If the command itself fails (e.g. connection not found),
// the event won't fire — handle here
const msg = e instanceof Error ? e.message : String(e);
useBackupStore.getState().failJob(jobId, msg);
}
}, [filePath, format, schema, noOwner, connectionId, startJob, notify]);
const toolsMissing = toolStatus && !toolStatus.pg_dump_found;
return (
<div className="flex flex-col h-full">
{/* Toolbar header */}
<div className="flex items-center gap-2 border-b border-border px-3 py-1.5">
<HardDrive size={14} className="text-accent" />
<span className="text-xs font-medium text-text">Backup</span>
<span className="text-[11px] text-text-muted">
Create a database backup via pg_dump
</span>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
<div className="max-w-lg mx-auto space-y-6 outline outline-border">
{/* Tool check */}
{checkingTools && (
<div className="glass p-4 text-center">
<p className="text-sm text-text-muted">
Checking for pg_dump...
</p>
</div>
)}
{toolsMissing && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2">
<p className="text-amber-300 text-sm font-semibold">
pg_dump not found
</p>
<p className="text-amber-200/80 text-xs leading-relaxed">
The PostgreSQL client tools are required for
backup/restore operations. Install them using:
</p>
<pre className="text-xs text-amber-100 bg-amber-500/10 rounded-lg p-3 whitespace-pre-wrap font-mono leading-relaxed">
{getPlatformInstructions()}
</pre>
</div>
)}
{!checkingTools && !toolsMissing && (
<>
{/* Configuration card */}
<div className="p-5 space-y-5">
{/* Format */}
<div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Format
</label>
<select
value={format}
onChange={(e) =>
setFormat(
e.target.value as BackupFormat,
)
}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
>
<option value="custom">
Custom Archive
</option>
<option value="plain">Plain SQL</option>
<option value="tar">Tarball</option>
<option value="directory">
Directory
</option>
</select>
</div>
{/* Output file */}
<div className="space-y-1 w-full">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Output File
</label>
<div className="flex gap-2">
<input
type="text"
value={filePath}
onChange={(e) =>
setFilePath(e.target.value)
}
placeholder="/path/to/backup.dump"
className="flex-1 px-4 py-2 text-sm text-text placeholder-text-muted/50 border-b border-border focus:border-accent focus:outline-none transition-colors"
/>
<button
type="button"
onClick={handlePickFile}
className="flex items-center justify-center w-9 h-9 rounded-lg border border-border bg-surface text-text-muted hover:text-text hover:bg-surface-raised hover:border-border-hover transition-colors cursor-pointer shrink-0"
aria-label="Browse for file"
>
<FolderOpen size={15} />
</button>
</div>
</div>
{/* Schema (optional) */}
<div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Schema{" "}
<span className="font-normal normal-case tracking-normal">
(optional)
</span>
</label>
<select
value={schema}
onChange={(e) =>
setSchema(e.target.value)
}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
>
<option value="">All schemas</option>
{availableSchemas.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
{/* No-owner toggle */}
<label className="flex items-center gap-2.5 cursor-pointer group">
<input
type="checkbox"
checked={noOwner}
onChange={(e) =>
setNoOwner(e.target.checked)
}
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
/>
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
No Owner{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
--no-owner
</code>
</span>
</label>
</div>
{/* Progress */}
{activeJob && (
<div className="px-4">
<BackupProgress
progress={activeJob.status === "completed" ? 100 : 50}
jobType="dump"
status={activeJob.status}
errorMessage={activeJob.error_message ?? undefined}
/>
</div>
)}
{/* Actions */}
<div className="flex justify-end pb-2 pr-2">
<Button
onClick={handleStartBackup}
disabled={isRunning || !filePath}
>
<Download size={14} className="mr-1.5" />
{isRunning ? "Backing up..." : "Start Backup"}
</Button>
</div>
</>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,61 @@
import { Button } from "../ui/Button";
interface BackupProgressProps {
progress: number;
jobType: string;
status: "running" | "completed" | "failed" | "cancelled";
errorMessage?: string | null;
onCancel?: () => void;
}
export function BackupProgress({
progress,
jobType,
status,
errorMessage,
onCancel,
}: BackupProgressProps) {
const isRunning = status === "running";
return (
<div data-testid="backup-progress" className="w-full space-y-3">
{/* Header row */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-text capitalize">
{jobType}
</span>
<span className="text-xs text-text-muted">
{status === "running" && `In progress...`}
{status === "completed" && "Completed"}
{status === "failed" && "Failed"}
{status === "cancelled" && "Cancelled"}
</span>
</div>
{isRunning && onCancel && (
<Button variant="ghost" onClick={onCancel}>
Cancel
</Button>
)}
</div>
{/* Progress bar */}
<div className="relative w-full h-2 bg-surface-raised rounded-full overflow-hidden">
<div
data-testid="progress-bar-fill"
className={`absolute left-0 top-0 h-full rounded-full transition-all duration-300 ${
status === "failed" ? "bg-red-500" : "bg-accent"
}`}
style={{
width: `${Math.min(100, Math.max(0, progress))}%`,
}}
/>
</div>
{/* Error message */}
{errorMessage && status === "failed" && (
<p className="text-sm text-red-400">{errorMessage}</p>
)}
</div>
);
}
@@ -1,79 +0,0 @@
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { DataGrid } from "./DataGrid";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import type { QueryResult } from "../../lib/types";
const mockData: QueryResult = {
columns: [{name:"id",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null},{name:"name",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null},{name:"email",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null}],
rows: [
[1, "Alice", "alice@example.com"],
[2, "Bob", null],
],
total_rows: 2, page: 1, page_size: 50,
};
describe("DataGrid", () => {
beforeEach(() => {
useDbViewerStore.getState().reset();
});
it("shows empty state when no active tab", () => {
render(<DataGrid connectionId="test-conn" rows={[]} selectedRows={new Set()} onSelectionChange={() => {}} />);
expect(screen.getByText(/Select a table to view data/i)).toBeInTheDocument();
});
it("shows loading state", () => {
useDbViewerStore.getState().openTab("public", "users");
const tabId = useDbViewerStore.getState().tabs[0].id;
useDbViewerStore.getState().setTabLoading(tabId, true);
render(<DataGrid connectionId="test-conn" rows={[]} selectedRows={new Set()} onSelectionChange={() => {}} />);
expect(screen.getByText(/Loading/i)).toBeInTheDocument();
});
it("shows error message in red", () => {
useDbViewerStore.getState().openTab("public", "users");
const tabId = useDbViewerStore.getState().tabs[0].id;
useDbViewerStore.getState().setTabError(tabId, "Connection failed");
render(<DataGrid connectionId="test-conn" rows={[]} selectedRows={new Set()} onSelectionChange={() => {}} />);
const error = screen.getByText(/Connection failed/i);
expect(error).toBeInTheDocument();
expect(error).toHaveClass("text-red-500");
});
it("shows loading state when first opening a tab", () => {
useDbViewerStore.getState().openTab("public", "users");
render(<DataGrid connectionId="test-conn" rows={[]} selectedRows={new Set()} onSelectionChange={() => {}} />);
expect(screen.getByText(/Loading/i)).toBeInTheDocument();
});
it("renders column headers and row data when loaded", () => {
useDbViewerStore.getState().openTab("public", "users");
const tabId = useDbViewerStore.getState().tabs[0].id;
useDbViewerStore.getState().setTabData(tabId, mockData);
render(<DataGrid connectionId="test-conn" rows={mockData.rows} selectedRows={new Set()} onSelectionChange={() => {}} />);
expect(screen.getByRole("columnheader", { name: /id/ })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: /name/ })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: /email/ })).toBeInTheDocument();
expect(screen.getByText("1")).toBeInTheDocument();
expect(screen.getByText("Alice")).toBeInTheDocument();
expect(screen.getByText("alice@example.com")).toBeInTheDocument();
expect(screen.getByText("Bob")).toBeInTheDocument();
expect(screen.getByText("NULL")).toBeInTheDocument();
});
it("renders NULL values as italic muted text", () => {
useDbViewerStore.getState().openTab("public", "users");
const tabId = useDbViewerStore.getState().tabs[0].id;
useDbViewerStore.getState().setTabData(tabId, mockData);
render(<DataGrid connectionId="test-conn" rows={mockData.rows} selectedRows={new Set()} onSelectionChange={() => {}} />);
const nullCell = screen.getByText("NULL");
expect(nullCell).toHaveClass("italic");
expect(nullCell).toHaveClass("text-text-muted");
});
});
-339
View File
@@ -1,339 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Key, Braces } from "lucide-react";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { abbreviateType } from "../../lib/utils";
import { FkPreviewPopover } from "./FkPreviewPopover";
import { JsonCellPopover, jsonPreview } from "./JsonCellPopover";
// TODO: Replace this plain HTML table with @tanstack/react-virtual for large
// result sets so we can render millions of rows without DOM overhead.
type ColumnWidths = Record<string, number>;
type TabColumnWidths = Record<string, ColumnWidths>;
const DEFAULT_COL_WIDTH = 200;
const MIN_COL_WIDTH = 60;
const MAX_COL_WIDTH = 800;
const CHECKBOX_COL_WIDTH = 40;
interface DataGridProps {
connectionId: string;
rows: unknown[][];
hiddenColumns?: Set<string>;
selectedRows: Set<number>;
onSelectionChange: (selected: Set<number>) => void;
}
export function DataGrid({ connectionId, rows, hiddenColumns, selectedRows, onSelectionChange }: DataGridProps) {
const tabs = useDbViewerStore((state) => state.tabs);
const activeTabId = useDbViewerStore((state) => state.activeTabId);
const [colWidths, setColWidths] = useState<TabColumnWidths>({});
// FK preview popover state
const [fkPreview, setFkPreview] = useState<{
connectionId: string;
schema: string;
table: string;
column: string;
value: string;
anchorRect: DOMRect | null;
} | null>(null);
// JSON cell popover state
const [jsonPopover, setJsonPopover] = useState<{
value: unknown;
anchorRect: DOMRect | null;
} | null>(null);
// ── helpers ────────────────────────────────────────────
const activeTab = activeTabId ? tabs.find((t) => t.id === activeTabId) : null;
const widths = activeTabId ? (colWidths[activeTabId] ?? {}) : {};
const getWidth = useCallback(
(colName: string) => widths[colName] ?? DEFAULT_COL_WIDTH,
[widths],
);
// ── selection logic ────────────────────────────────────
const allSelected = rows.length > 0 && selectedRows.size === rows.length;
const someSelected = selectedRows.size > 0 && selectedRows.size < rows.length;
const checkboxRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (checkboxRef.current) {
checkboxRef.current.indeterminate = someSelected;
}
}, [someSelected]);
const toggleAll = () => {
if (allSelected) {
onSelectionChange(new Set());
} else {
onSelectionChange(new Set(rows.map((_, i) => i)));
}
};
const toggleRow = (rowIndex: number) => {
const next = new Set(selectedRows);
if (next.has(rowIndex)) next.delete(rowIndex);
else next.add(rowIndex);
onSelectionChange(next);
};
// ── resize handler (ref-based to avoid stale closures) ─
const resizeRef = useRef<{ col: string; startX: number; startWidth: number } | null>(null);
const startResize = useCallback(
(colName: string, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
resizeRef.current = { col: colName, startX: e.clientX, startWidth: getWidth(colName) };
const onMove = (ev: MouseEvent) => {
if (!resizeRef.current) return;
const delta = ev.clientX - resizeRef.current.startX;
const next = Math.max(MIN_COL_WIDTH, Math.min(MAX_COL_WIDTH, resizeRef.current.startWidth + delta));
setColWidths((prev) => ({
...prev,
[activeTabId!]: { ...(prev[activeTabId!] ?? {}), [resizeRef.current!.col]: next },
}));
};
const onUp = () => {
resizeRef.current = null;
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
},
[activeTabId, getWidth],
);
// ── FK row-click handler ───────────────────────────────
const handleFkClick = useCallback(
(col: { name: string; is_fk: boolean; fk_ref: [string, string] | null }, cellValue: unknown, e: React.MouseEvent) => {
if (!col.is_fk || !col.fk_ref || cellValue === null || cellValue === undefined) return;
const [refTable] = col.fk_ref;
const schema = activeTab?.schema ?? "public";
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setFkPreview({
connectionId,
schema,
table: refTable,
column: col.fk_ref[1],
value: String(cellValue),
anchorRect: rect,
});
},
[activeTab],
);
// ── empty / loading / error states ─────────────────────
if (!activeTabId) {
return (
<div className="flex h-full items-center justify-center text-sm text-text-muted">
Select a table to view data
</div>
);
}
if (!activeTab) {
return (
<div className="flex h-full items-center justify-center text-sm text-text-muted">
Select a table to view data
</div>
);
}
if (activeTab.loading && !activeTab.data) {
return (
<div className="flex h-full items-center justify-center text-sm text-text-muted">
Loading...
</div>
);
}
if (activeTab.error) {
return (
<div className="flex h-full items-center justify-center p-4 text-sm text-red-500">
{activeTab.error}
</div>
);
}
if (!activeTab.data) {
return (
<div className="flex h-full items-center justify-center text-sm text-text-muted">
Loading table data...
</div>
);
}
const { columns } = activeTab.data;
// Filter visible columns
const visibleColumns = hiddenColumns
? columns.filter((c) => !hiddenColumns.has(c.name))
: columns;
return (
<div
className="flex-1 overflow-auto min-w-0 relative"
style={{ overscrollBehavior: "none", WebkitOverflowScrolling: "auto" }}
>
{/* Loading indicator bar when refreshing with existing data */}
{activeTab.loading && (
<div className="absolute top-0 left-0 right-0 h-0.5 bg-accent z-20 animate-pulse" />
)}
<table
className="border-collapse text-left text-sm"
style={{ tableLayout: "fixed", width: "100%" }}
>
<colgroup>
{/* Checkbox column */}
<col style={{ width: CHECKBOX_COL_WIDTH, minWidth: CHECKBOX_COL_WIDTH }} />
{visibleColumns.map((col) => (
<col key={col.name} style={{ width: getWidth(col.name) }} />
))}
</colgroup>
<thead className="sticky top-0 z-10 bg-surface">
<tr>
{/* Header checkbox */}
<th
scope="col"
className="border-b border-r border-border px-0 py-2 w-[40px]"
>
<div className="flex items-center justify-center">
<input
ref={checkboxRef}
type="checkbox"
checked={allSelected}
onChange={toggleAll}
className="w-3.5 h-3.5 rounded border-border cursor-pointer accent-accent"
/>
</div>
</th>
{visibleColumns.map((col) => (
<th
key={col.name}
scope="col"
role="columnheader"
className="group relative border-b border-r border-border px-3 py-2 font-heading text-text-muted last:border-r-0"
style={{ width: getWidth(col.name), maxWidth: getWidth(col.name) }}
>
<div className="truncate flex items-center gap-1">
{col.is_pk && <Key size={10} className="text-accent shrink-0" />}
{col.is_fk && <Key size={10} className="text-amber-400 shrink-0" />}
<span className="text-text text-xs">{col.name}</span>
<span className="ml-1 text-[10px] text-text-muted/60" title={col.data_type}>
{abbreviateType(col.data_type)}
</span>
</div>
{/* resize handle */}
<div
className="absolute right-0 top-0 h-full w-[6px] cursor-col-resize select-none bg-transparent hover:bg-accent/30 active:bg-accent/50"
onMouseDown={(e) => startResize(col.name, e)}
onDoubleClick={() => {
setColWidths((prev) => ({
...prev,
[activeTabId!]: { ...(prev[activeTabId!] ?? {}), [col.name]: DEFAULT_COL_WIDTH },
}));
}}
/>
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, rowIndex) => {
const isSelected = selectedRows.has(rowIndex);
return (
<tr
key={rowIndex}
className={`border-b border-border hover:bg-surface/50 ${isSelected ? "bg-accent/5" : ""}`}
>
{/* Row checkbox */}
<td className="border-r border-border px-0 py-2" style={{ overflow: "hidden" }}>
<div className="flex items-center justify-center">
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleRow(rowIndex)}
className="w-3.5 h-3.5 rounded border-border cursor-pointer accent-accent"
/>
</div>
</td>
{visibleColumns.map((col) => {
const ci = columns.findIndex((c) => c.name === col.name);
const cell = ci >= 0 ? row[ci] : undefined;
const isNull = cell === null || cell === undefined;
const isFk = col.is_fk && col.fk_ref && !isNull;
const isJson = !isNull && (col.data_type === "jsonb" || col.data_type === "json");
const jp = isJson ? jsonPreview(cell) : { label: "", isJson: false };
const handleJsonClick = (e: React.MouseEvent) => {
if (isJson) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setJsonPopover({ value: cell, anchorRect: rect });
}
};
return (
<td key={col.name} className="border-r border-border px-3 py-2 last:border-r-0 font-heading text-xs" style={{ overflow: "hidden" }}>
<div
className={`truncate max-w-full select-text ${isFk ? "cursor-pointer underline decoration-dotted underline-offset-2 hover:text-accent" : ""} ${isJson ? "cursor-pointer text-accent/80 hover:text-accent" : ""}`}
title={isNull ? "NULL" : isFk ? `FK → ${col!.fk_ref![0]}.${col!.fk_ref![1]}: ${String(cell)}` : isJson ? "Click to view JSON" : String(cell)}
onClick={isFk ? (e) => handleFkClick(col!, cell, e) : isJson ? handleJsonClick : undefined}
role={isFk || isJson ? "button" : undefined}
tabIndex={isFk || isJson ? 0 : undefined}
onKeyDown={isFk ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); handleFkClick(col!, cell, e as any); } } : isJson ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); handleJsonClick(e as any); } } : undefined}
>
{isNull ? (
<span className="italic text-text-muted">NULL</span>
) : isJson ? (
<span className="inline-flex items-center gap-0.5">
<Braces size={10} className="shrink-0" />
{jp.label}
</span>
) : (
String(cell)
)}
</div>
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
{/* FK preview popover */}
{fkPreview && (
<FkPreviewPopover
connectionId={fkPreview.connectionId}
schema={fkPreview.schema}
table={fkPreview.table}
column={fkPreview.column}
value={fkPreview.value}
anchorRect={fkPreview.anchorRect}
onClose={() => setFkPreview(null)}
/>
)}
{/* JSON cell popover */}
{jsonPopover && (
<JsonCellPopover
value={jsonPopover.value}
anchorRect={jsonPopover.anchorRect}
onClose={() => setJsonPopover(null)}
/>
)}
</div>
);
}
@@ -1,8 +1,16 @@
import { describe, it, expect, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { DbViewerScreen } from "./DbViewerScreen";
import { useDbViewerStore } from "../../stores/dbViewerStore";
vi.mock("@tanstack/react-virtual", () => ({
useVirtualizer: () => ({
getVirtualItems: () => [],
getTotalSize: () => 0,
measureElement: () => {},
}),
}));
describe("DbViewerScreen", () => {
beforeEach(() => {
useDbViewerStore.setState({
+564 -376
View File
@@ -1,10 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { TooltipProvider } from "../ui/Tooltip";
import { DbViewerSidebar } from "./DbViewerSidebar";
import { DbViewerToolbar } from "./DbViewerToolbar";
import { TableTree } from "./TableTree";
import { ObjectExplorerPage } from "./ObjectExplorerPage";
import { TabBar } from "./TabBar";
import { DataGrid } from "./DataGrid";
import { VirtualDataGrid } from "../grid/VirtualDataGrid";
import { ChangesQueuePanel } from "./ChangesQueuePanel";
import { TableControls } from "./TableControls";
import { EditConnectionModal } from "./EditConnectionModal";
@@ -14,395 +15,582 @@ import { useConnectionStore } from "../../stores/connectionStore";
import { useSettingsStore } from "../../stores/settingsStore";
import { useShortcut } from "../../hooks/useShortcut";
import { ConnectionDropBanner } from "./ConnectionDropBanner";
import { BackupPage } from "./BackupPage";
import { RestorePage } from "./RestorePage";
import { SyncPage } from "./SyncPage";
import * as cmd from "../../lib/commands";
import type { ColumnInfo } from "../../lib/types";
export interface DbViewerScreenProps {
connectionId: string;
onHome: () => void;
onSettings: () => void;
connectionId: string;
onHome: () => void;
onSettings: () => void;
}
// ─── client-side filter/sort helpers ─────────────────────
export function DbViewerScreen({
connectionId,
onHome,
onSettings,
}: DbViewerScreenProps) {
const { connectionError, connect } = useDbConnection(connectionId);
const [dismissedError, setDismissedError] = useState<string | null>(null);
const [currentView, setCurrentView] = useState<string>("db-viewer");
const [tablePanelWidth, setTablePanelWidth] = useState(280);
const [searchQuery, setSearchQuery] = useState("");
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
const [editModalOpen, setEditModalOpen] = useState(false);
const connections = useConnectionStore((s) => s.connections);
const currentConnection =
connections.find((c) => c.id === connectionId) ?? null;
const settings = useSettingsStore((s) => s.settings);
const setDefaultPageSize = useDbViewerStore((s) => s.setDefaultPageSize);
const clearColumnFilter = useDbViewerStore((s) => s.clearColumnFilter);
const setFilterRules = useDbViewerStore((s) => s.setFilterRules);
const setSortRules = useDbViewerStore((s) => s.setSortRules);
const toggleHiddenColumn = useDbViewerStore((s) => s.toggleHiddenColumn);
const setSmartSortApplied = useDbViewerStore((s) => s.setSmartSortApplied);
type FilterRule = {
id: string;
column: string;
operator: "eq" | "neq" | "contains" | "starts" | "ends" | "gt" | "lt" | "null" | "notnull";
value: string;
};
type SortRule = { id: string; column: string; order: "asc" | "desc" };
function applyFilters(rows: unknown[][], columns: ColumnInfo[], rules: FilterRule[]): unknown[][] {
if (rules.length === 0) return rows;
return rows.filter((row) =>
rules.every((rule) => {
const ci = columns.findIndex((c) => c.name === rule.column);
if (ci < 0) return true;
const cell = row[ci];
const str = cell === null || cell === undefined ? "" : String(cell);
switch (rule.operator) {
case "null": return cell === null;
case "notnull": return cell !== null;
case "eq": return str === rule.value;
case "neq": return str !== rule.value;
case "contains": return str.toLowerCase().includes(rule.value.toLowerCase());
case "starts": return str.toLowerCase().startsWith(rule.value.toLowerCase());
case "ends": return str.toLowerCase().endsWith(rule.value.toLowerCase());
case "gt": return Number(str) > Number(rule.value);
case "lt": return Number(str) < Number(rule.value);
default: return true;
}
}),
);
}
function applySorts(rows: unknown[][], columns: ColumnInfo[], rules: SortRule[]): unknown[][] {
if (rules.length === 0) return rows;
return [...rows].sort((a, b) => {
for (const rule of rules) {
const ci = columns.findIndex((c) => c.name === rule.column);
if (ci < 0) continue;
const va = a[ci];
const vb = b[ci];
const cmp =
va === null && vb === null ? 0
: va === null ? -1
: vb === null ? 1
: String(va).localeCompare(String(vb), undefined, { numeric: true });
if (cmp !== 0) return rule.order === "asc" ? cmp : -cmp;
}
return 0;
});
}
export function DbViewerScreen({ connectionId, onHome, onSettings }: DbViewerScreenProps) {
const { connectionError, connect } = useDbConnection(connectionId);
const [dismissedError, setDismissedError] = useState<string | null>(null);
const [tablePanelWidth, setTablePanelWidth] = useState(280);
const [hiddenColumns, setHiddenColumns] = useState<Set<string>>(new Set());
const [filterRules, setFilterRules] = useState<FilterRule[]>([]);
const [sortRules, setSortRules] = useState<SortRule[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const smartSortApplied = useRef<Set<string>>(new Set());
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
const [editModalOpen, setEditModalOpen] = useState(false);
const connections = useConnectionStore((s) => s.connections);
const currentConnection = connections.find((c) => c.id === connectionId) ?? null;
const settings = useSettingsStore((s) => s.settings);
const setDefaultPageSize = useDbViewerStore((s) => s.setDefaultPageSize);
const clearColumnFilter = useDbViewerStore((s) => s.clearColumnFilter);
// Sync settings defaults to store
useEffect(() => {
if (settings?.table_page_size) {
setDefaultPageSize(settings.table_page_size);
}
}, [settings?.table_page_size, setDefaultPageSize]);
const panelResizeRef = useRef<{ startX: number; startW: number } | null>(null);
const activeTab = useDbViewerStore((s) => {
if (!s.activeTabId) return null;
return s.tabs.find((t) => t.id === s.activeTabId) ?? null;
});
const setTabData = useDbViewerStore((s) => s.setTabData);
const setTabError = useDbViewerStore((s) => s.setTabError);
const databases = useDbViewerStore((s) => s.databases);
const currentDatabase = useDbViewerStore((s) => s.currentDatabase);
const setCurrentDatabase = useDbViewerStore((s) => s.setCurrentDatabase);
const schemas = useDbViewerStore((s) => s.schemas);
const currentSchema = useDbViewerStore((s) => s.currentSchema);
const setCurrentSchema = useDbViewerStore((s) => s.setCurrentSchema);
const fetchingRef = useRef<Set<string>>(new Set());
const fetchData = useCallback(async (tab: NonNullable<typeof activeTab>) => {
if (fetchingRef.current.has(tab.id)) return;
fetchingRef.current.add(tab.id);
try {
const result = await cmd.getTableData(
connectionId,
tab.schema,
tab.table,
tab.page,
tab.pageSize,
);
setTabData(tab.id, result);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
setTabError(tab.id, msg);
} finally {
fetchingRef.current.delete(tab.id);
}
}, [connectionId, setTabData, setTabError]);
// Cmd+W / Ctrl+W: close current tab, or navigate home if no tabs (configurable in Settings → Shortcuts)
useShortcut("close_tab", () => {
const state = useDbViewerStore.getState();
if (state.activeTabId) {
state.closeTab(state.activeTabId);
} else {
onHome();
}
});
useEffect(() => {
if (!activeTab) return;
if (!activeTab.loading) return;
if (activeTab.error) return;
fetchData(activeTab);
}, [activeTab, fetchData]);
// Smart default sort: apply once when data first loads for a tab
useEffect(() => {
if (!activeTab) return;
if (activeTab.loading) return;
if (!activeTab.data) return;
if (smartSortApplied.current.has(activeTab.id)) return;
const cols = activeTab.data.columns;
const getColType = (name: string) => {
const col = cols.find((c) => c.name.toLowerCase() === name.toLowerCase());
return col?.data_type.toLowerCase() ?? "";
};
const isNumeric = (name: string) => {
const t = getColType(name);
return ["integer", "int", "int2", "int4", "int8", "smallint", "bigint",
"serial", "bigserial", "smallserial", "tinyint", "mediumint",
"numeric", "decimal", "real", "float", "float4", "float8",
"double precision", "double", "number"].includes(t);
};
const isTimestamp = (name: string) => {
const t = getColType(name);
return ["timestamp", "timestamptz", "timestamp without time zone",
"timestamp with time zone", "date", "datetime", "datetime2",
"smalldatetime"].some((pt) => t.includes(pt));
};
// Find the first column name that exists and passes type checks
const findCol = (candidates: string[], numericOnly = false): string | undefined => {
for (const cand of candidates) {
const match = cols.find((c) => c.name.toLowerCase() === cand.toLowerCase());
if (!match) continue;
if (numericOnly && !isNumeric(match.name)) continue;
return match.name;
}
return undefined;
};
const findBySuffix = (suffixes: string[], numericOnly = false): string | undefined => {
for (const c of cols) {
const name = c.name.toLowerCase();
if (suffixes.some((s) => name.endsWith(s))) {
if (numericOnly && !isNumeric(c.name)) continue;
return c.name;
// Sync settings defaults to store
useEffect(() => {
if (settings?.table_page_size) {
setDefaultPageSize(settings.table_page_size);
}
}
return undefined;
};
const findByPrefix = (prefixes: string[], numericOnly = false): string | undefined => {
for (const c of cols) {
const name = c.name.toLowerCase();
if (prefixes.some((p) => name.startsWith(p))) {
if (numericOnly && !isNumeric(c.name)) continue;
return c.name;
}
}
return undefined;
};
}, [settings?.table_page_size, setDefaultPageSize]);
const panelResizeRef = useRef<{ startX: number; startW: number } | null>(
null,
);
// Priority-ordered rules: each returns [columnName | undefined, order]
const rules: Array<() => [string | undefined, "asc" | "desc"]> = [
// Tier 1: Explicit recency columns
() => [findCol(["updated_at", "modified_at", "changed_at", "altered_at", "revised_at"]), "desc"],
() => [findCol(["created_at", "inserted_at", "added_at", "published_at", "posted_at", "registered_at"]), "desc"],
() => [findCol(["deleted_at", "removed_at", "expired_at", "archived_at"]), "desc"],
// Tier 2: Generic date/timestamp columns (DESC = newest)
() => {
const col = cols.find((c) => isTimestamp(c.name));
return col ? [col.name, "desc"] : [undefined, "desc"];
},
// Tier 3: Any *_at suffix (covers updated_at, created_at, etc. in any casing)
() => [findBySuffix(["_at"]), "desc"],
// Tier 4: Any *_on suffix (e.g. action_on, performed_on)
() => [findBySuffix(["_on"]), "desc"],
// Tier 5: last_* prefix (e.g. last_login, last_seen, last_modified)
() => [findByPrefix(["last_"]), "desc"],
// Tier 6: Numeric ID (DESC = highest/newest)
() => [findCol(["id", "uid", "pk"], true), "desc"],
// Tier 7: Any *_id suffix (numeric FKs usually increment)
() => [findBySuffix(["_id"], true), "desc"],
// Tier 8: Sequence/order columns (ASC = natural order)
() => [findCol(["seq", "sequence", "ordinal", "sort", "sort_order", "sortorder", "position", "pos", "display_order"], true), "asc"],
// Tier 9: Rank/priority (ASC if lower = higher priority, DESC if higher = more)
() => [findCol(["rank", "ranking", "priority", "weight", "score", "rating"], true), "desc"],
// Tier 10: Version/revision tracking (DESC = latest)
() => [findCol(["version", "revision", "rev", "build", "release"], true), "desc"],
// Tier 11: Count/quantity (DESC = most)
() => [findCol(["count", "total", "amount", "quantity", "qty", "num", "number", "no"], true), "desc"],
];
for (const rule of rules) {
const [colName, order] = rule();
if (colName) {
smartSortApplied.current.add(activeTab.id);
setSortRules([{ id: crypto.randomUUID(), column: colName, order }]);
return;
}
}
}, [activeTab]);
// Sync tab columnFilter (set by FK popover) into the toolbar filterRules
useEffect(() => {
if (!activeTab?.columnFilter) return;
const { column, value } = activeTab.columnFilter;
setFilterRules((prev) => {
const exists = prev.some((r) => r.column === column && r.value === value);
if (exists) return prev;
return [...prev, { id: crypto.randomUUID(), column, operator: "contains" as const, value }];
const activeTab = useDbViewerStore((s) => {
if (!s.activeTabId) return null;
return s.tabs.find((t) => t.id === s.activeTabId) ?? null;
});
}, [activeTab?.columnFilter]);
// When the FK filter rule is removed from the toolbar, clear the tab's columnFilter
useEffect(() => {
if (!activeTab?.columnFilter) return;
const { column, value } = activeTab.columnFilter;
const stillExists = filterRules.some((r) => r.column === column && r.value === value);
if (!stillExists) {
clearColumnFilter(activeTab.id);
}
}, [filterRules, activeTab, clearColumnFilter]);
// Derive per-tab toolbar state from active tab
const filterRules = activeTab?.filterRules ?? [];
const sortRules = activeTab?.sortRules ?? [];
const hiddenColumns = new Set(activeTab?.hiddenColumns ?? []);
// Refresh: clear data so auto-fetch effect re-fetches
const handleRefresh = useCallback(() => {
const tabId = useDbViewerStore.getState().activeTabId;
if (!tabId) return;
useDbViewerStore.setState((s) => ({
tabs: s.tabs.map((t) =>
t.id === tabId ? { ...t, loading: true, error: null } : t,
),
}));
}, []);
const setTabData = useDbViewerStore((s) => s.setTabData);
const setTabError = useDbViewerStore((s) => s.setTabError);
const databases = useDbViewerStore((s) => s.databases);
const currentDatabase = useDbViewerStore((s) => s.currentDatabase);
const setCurrentDatabase = useDbViewerStore((s) => s.setCurrentDatabase);
const schemas = useDbViewerStore((s) => s.schemas);
const currentSchema = useDbViewerStore((s) => s.currentSchema);
const setCurrentSchema = useDbViewerStore((s) => s.setCurrentSchema);
const fetchingRef = useRef<Set<string>>(new Set());
const rawRows = activeTab?.data?.rows ?? [];
const columns = activeTab?.data?.columns ?? [];
const processedRows = useMemo(() => {
let result = rawRows;
result = applyFilters(result, columns, filterRules);
result = applySorts(result, columns, sortRules);
return result;
}, [rawRows, columns, filterRules, sortRules]);
const fetchData = useCallback(
async (tab: NonNullable<typeof activeTab>) => {
if (fetchingRef.current.has(tab.id)) return;
fetchingRef.current.add(tab.id);
try {
const result = await cmd.getTableData(
connectionId,
tab.schema,
tab.table,
tab.page,
tab.pageSize,
tab.filterRules,
tab.sortRules,
);
setTabData(tab.id, result);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
setTabError(tab.id, msg);
} finally {
fetchingRef.current.delete(tab.id);
}
},
[connectionId, setTabData, setTabError],
);
const onPanelResizeStart = useCallback((e: React.MouseEvent) => {
e.preventDefault();
panelResizeRef.current = { startX: e.clientX, startW: tablePanelWidth };
const onMove = (ev: MouseEvent) => {
if (!panelResizeRef.current) return;
const w = Math.max(180, Math.min(600, panelResizeRef.current.startW + (ev.clientX - panelResizeRef.current.startX)));
setTablePanelWidth(w);
};
const onUp = () => {
panelResizeRef.current = null;
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
}, [tablePanelWidth]);
// Cmd+W / Ctrl+W: close current tab, or navigate home if no tabs (configurable in Settings → Shortcuts)
useShortcut("close_tab", () => {
const state = useDbViewerStore.getState();
if (state.activeTabId) {
state.closeTab(state.activeTabId);
} else {
onHome();
}
});
useEffect(() => {
if (!activeTab) return;
if (!activeTab.loading) return;
if (activeTab.error) return;
fetchData(activeTab);
}, [activeTab, fetchData]);
const handleNavigate = useCallback(
(view: string) => {
if (view === "home") onHome();
else if (view === "settings") onSettings();
},
[onHome, onSettings],
);
// Smart default sort: apply once when data first loads for a tab
useEffect(() => {
if (!activeTab) return;
if (activeTab.loading) return;
if (!activeTab.data) return;
if (activeTab.smartSortApplied) return;
const activeSchema = activeTab?.schema ?? "";
const activeTable = activeTab?.table ?? "";
const cols = activeTab.data.columns;
return (
<TooltipProvider>
<div className="h-screen bg-canvas flex border-t border-border">
<DbViewerSidebar currentView="db-viewer" onNavigate={handleNavigate} />
<div className="flex-1 flex flex-col min-h-0">
{connectionError && connectionError !== dismissedError && (
<ConnectionDropBanner
error={connectionError}
onRetry={() => {
setDismissedError(null);
connect();
}}
onDismiss={() => setDismissedError(connectionError)}
/>
)}
<div className="flex flex-1 min-h-0 overflow-hidden">
<div className="border-r border-border flex flex-col shrink-0" style={{ width: tablePanelWidth }}>
<DbViewerToolbar
databases={databases}
currentDatabase={currentDatabase}
setCurrentDatabase={setCurrentDatabase}
schemas={schemas}
currentSchema={currentSchema}
setCurrentSchema={setCurrentSchema}
onEdit={() => setEditModalOpen(true)}
connectionId={connectionId}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
/>
<div className="flex-1 overflow-y-auto" style={{ overscrollBehavior: "none" }}>
<TableTree searchQuery={searchQuery} />
</div>
</div>
{/* panel resize handle */}
<div
className="w-[5px] cursor-col-resize hover:bg-accent/30 active:bg-accent/50 shrink-0"
onMouseDown={onPanelResizeStart}
onDoubleClick={() => setTablePanelWidth(280)}
/>
<div className="flex-1 w-0 flex flex-col min-w-0 overflow-hidden">
<TabBar />
{activeTab?.data && (
<TableControls
connectionId={connectionId}
schema={activeSchema}
table={activeTable}
columns={columns}
rows={rawRows}
hiddenColumns={hiddenColumns}
onToggleColumn={(col) =>
setHiddenColumns((prev) => {
const next = new Set(prev);
if (next.has(col)) next.delete(col); else next.add(col);
return next;
})
}
onRefresh={handleRefresh}
filterRules={filterRules}
onFilterChange={setFilterRules}
sortRules={sortRules}
onSortChange={setSortRules}
defaultRefreshRate={settings?.table_refresh_rate ?? 0}
selectedCount={selectedRows.size}
selectedRows={processedRows.filter((_, i) => selectedRows.has(i))}
onClearSelection={() => setSelectedRows(new Set())}
const getColType = (name: string) => {
const col = cols.find(
(c) => c.name.toLowerCase() === name.toLowerCase(),
);
return col?.data_type.toLowerCase() ?? "";
};
const isNumeric = (name: string) => {
const t = getColType(name);
return [
"integer",
"int",
"int2",
"int4",
"int8",
"smallint",
"bigint",
"serial",
"bigserial",
"smallserial",
"tinyint",
"mediumint",
"numeric",
"decimal",
"real",
"float",
"float4",
"float8",
"double precision",
"double",
"number",
].includes(t);
};
const isTimestamp = (name: string) => {
const t = getColType(name);
return [
"timestamp",
"timestamptz",
"timestamp without time zone",
"timestamp with time zone",
"date",
"datetime",
"datetime2",
"smalldatetime",
].some((pt) => t.includes(pt));
};
// Find the first column name that exists and passes type checks
const findCol = (
candidates: string[],
numericOnly = false,
): string | undefined => {
for (const cand of candidates) {
const match = cols.find(
(c) => c.name.toLowerCase() === cand.toLowerCase(),
);
if (!match) continue;
if (numericOnly && !isNumeric(match.name)) continue;
return match.name;
}
return undefined;
};
const findBySuffix = (
suffixes: string[],
numericOnly = false,
): string | undefined => {
for (const c of cols) {
const name = c.name.toLowerCase();
if (suffixes.some((s) => name.endsWith(s))) {
if (numericOnly && !isNumeric(c.name)) continue;
return c.name;
}
}
return undefined;
};
const findByPrefix = (
prefixes: string[],
numericOnly = false,
): string | undefined => {
for (const c of cols) {
const name = c.name.toLowerCase();
if (prefixes.some((p) => name.startsWith(p))) {
if (numericOnly && !isNumeric(c.name)) continue;
return c.name;
}
}
return undefined;
};
// Priority-ordered rules: each returns [columnName | undefined, order]
const rules: Array<() => [string | undefined, "asc" | "desc"]> = [
// Tier 1: Explicit recency columns
() => [
findCol([
"updated_at",
"modified_at",
"changed_at",
"altered_at",
"revised_at",
]),
"desc",
],
() => [
findCol([
"created_at",
"inserted_at",
"added_at",
"published_at",
"posted_at",
"registered_at",
]),
"desc",
],
() => [
findCol([
"deleted_at",
"removed_at",
"expired_at",
"archived_at",
]),
"desc",
],
// Tier 2: Generic date/timestamp columns (DESC = newest)
() => {
const col = cols.find((c) => isTimestamp(c.name));
return col ? [col.name, "desc"] : [undefined, "desc"];
},
// Tier 3: Any *_at suffix (covers updated_at, created_at, etc. in any casing)
() => [findBySuffix(["_at"]), "desc"],
// Tier 4: Any *_on suffix (e.g. action_on, performed_on)
() => [findBySuffix(["_on"]), "desc"],
// Tier 5: last_* prefix (e.g. last_login, last_seen, last_modified)
() => [findByPrefix(["last_"]), "desc"],
// Tier 6: Numeric ID (DESC = highest/newest)
() => [findCol(["id", "uid", "pk"], true), "desc"],
// Tier 7: Any *_id suffix (numeric FKs usually increment)
() => [findBySuffix(["_id"], true), "desc"],
// Tier 8: Sequence/order columns (ASC = natural order)
() => [
findCol(
[
"seq",
"sequence",
"ordinal",
"sort",
"sort_order",
"sortorder",
"position",
"pos",
"display_order",
],
true,
),
"asc",
],
// Tier 9: Rank/priority (ASC if lower = higher priority, DESC if higher = more)
() => [
findCol(
[
"rank",
"ranking",
"priority",
"weight",
"score",
"rating",
],
true,
),
"desc",
],
// Tier 10: Version/revision tracking (DESC = latest)
() => [
findCol(
["version", "revision", "rev", "build", "release"],
true,
),
"desc",
],
// Tier 11: Count/quantity (DESC = most)
() => [
findCol(
[
"count",
"total",
"amount",
"quantity",
"qty",
"num",
"number",
"no",
],
true,
),
"desc",
],
];
for (const rule of rules) {
const [colName, order] = rule();
if (colName) {
setSmartSortApplied(activeTab.id);
setSortRules(activeTab.id, [
{ id: crypto.randomUUID(), column: colName, order },
]);
return;
}
}
}, [activeTab]);
// Sync tab columnFilter (set by FK popover) into the toolbar filterRules
useEffect(() => {
if (!activeTab?.columnFilter) return;
const { column, value } = activeTab.columnFilter;
const currentRules = activeTab.filterRules ?? [];
const exists = currentRules.some(
(r) => r.column === column && r.value === value,
);
if (exists) return;
setFilterRules(activeTab.id, [
...currentRules,
{
id: crypto.randomUUID(),
column,
operator: "contains" as const,
value,
},
]);
}, [activeTab?.columnFilter, activeTab?.id, activeTab?.filterRules, setFilterRules]);
// When the FK filter rule is removed from the toolbar, clear the tab's columnFilter
useEffect(() => {
if (!activeTab?.columnFilter) return;
const { column, value } = activeTab.columnFilter;
const stillExists = filterRules.some(
(r) => r.column === column && r.value === value,
);
if (!stillExists) {
clearColumnFilter(activeTab.id);
}
}, [filterRules, activeTab, clearColumnFilter]);
// Refresh: clear data so auto-fetch effect re-fetches
const handleRefresh = useCallback(() => {
const tabId = useDbViewerStore.getState().activeTabId;
if (!tabId) return;
useDbViewerStore.setState((s) => ({
tabs: s.tabs.map((t) =>
t.id === tabId ? { ...t, loading: true, error: null } : t,
),
}));
}, []);
const rawRows = activeTab?.data?.rows ?? [];
const columns = activeTab?.data?.columns ?? [];
// Data is already filtered and sorted server-side; no client-side transform needed.
const processedRows = rawRows;
const onPanelResizeStart = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
panelResizeRef.current = {
startX: e.clientX,
startW: tablePanelWidth,
};
const onMove = (ev: MouseEvent) => {
if (!panelResizeRef.current) return;
const w = Math.max(
180,
Math.min(
600,
panelResizeRef.current.startW +
(ev.clientX - panelResizeRef.current.startX),
),
);
setTablePanelWidth(w);
};
const onUp = () => {
panelResizeRef.current = null;
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
},
[tablePanelWidth],
);
const handleNavigate = useCallback(
(view: string) => {
if (view === "home") onHome();
else if (view === "settings") onSettings();
else setCurrentView(view);
},
[onHome, onSettings],
);
const activeSchema = activeTab?.schema ?? "";
const activeTable = activeTab?.table ?? "";
return (
<TooltipProvider>
<div className="h-screen bg-canvas flex border-t border-border">
<DbViewerSidebar
currentView={currentView}
onNavigate={handleNavigate}
/>
)}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<DataGrid connectionId={connectionId} rows={processedRows} hiddenColumns={hiddenColumns} selectedRows={selectedRows} onSelectionChange={setSelectedRows} />
</div>
<div className="flex-1 flex flex-col min-h-0">
{connectionError && connectionError !== dismissedError && (
<ConnectionDropBanner
error={connectionError}
onRetry={() => {
setDismissedError(null);
connect();
}}
onDismiss={() => setDismissedError(connectionError)}
/>
)}
{currentView === "db-viewer" ? (
<div className="flex flex-1 min-h-0 overflow-hidden">
<div
className="border-r border-border flex flex-col shrink-0"
style={{ width: tablePanelWidth }}
>
<DbViewerToolbar
databases={databases}
currentDatabase={currentDatabase}
setCurrentDatabase={setCurrentDatabase}
schemas={schemas}
currentSchema={currentSchema}
setCurrentSchema={setCurrentSchema}
onEdit={() => setEditModalOpen(true)}
connectionId={connectionId}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
/>
<div
className="flex-1 overflow-y-auto"
style={{ overscrollBehavior: "none" }}
>
<TableTree searchQuery={searchQuery} />
</div>
</div>
{/* panel resize handle */}
<div
className="w-1 cursor-col-resize bg-border/20 hover:bg-accent/30 active:bg-accent/50 shrink-0 border-r border-border"
onMouseDown={onPanelResizeStart}
onDoubleClick={() => setTablePanelWidth(280)}
/>
<div className="flex-1 w-0 flex flex-col min-w-0 overflow-hidden">
<TabBar />
{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())
}
/>
)}
<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>
</div>
) : currentView === "functions" ? (
<ObjectExplorerPage
key="functions"
type="functions"
connectionId={connectionId}
/>
) : currentView === "triggers" ? (
<ObjectExplorerPage
key="triggers"
type="triggers"
connectionId={connectionId}
/>
) : currentView === "sequences" ? (
<ObjectExplorerPage
key="sequences"
type="sequences"
connectionId={connectionId}
/>
) : currentView === "enums" ? (
<ObjectExplorerPage key="enums" type="enums" connectionId={connectionId} />
) : currentView === "extensions" ? (
<ObjectExplorerPage
key="extensions"
type="extensions"
connectionId={connectionId}
/>
) : currentView === "backup" ? (
<BackupPage connectionId={connectionId} />
) : currentView === "restore" ? (
<RestorePage connectionId={connectionId} />
) : currentView === "sync" ? (
<SyncPage />
) : null}
{currentView === "db-viewer" && <ChangesQueuePanel />}
</div>
{currentConnection && (
<EditConnectionModal
connection={currentConnection}
open={editModalOpen}
onClose={() => setEditModalOpen(false)}
onSaved={() => {}}
/>
)}
</div>
</div>
<ChangesQueuePanel />
</div>
{currentConnection && (
<EditConnectionModal
connection={currentConnection}
open={editModalOpen}
onClose={() => setEditModalOpen(false)}
onSaved={() => {}}
/>
)}
</div>
</TooltipProvider>
);
}
</TooltipProvider>
);
}
+89 -44
View File
@@ -1,57 +1,102 @@
import { Database, Grid2x2, FunctionSquare, GitBranch, Home, Settings } from "lucide-react";
import {
ArrowLeftRight,
Database,
Download,
FunctionSquare,
GitBranch,
Grid2x2,
Home,
ListOrdered,
Puzzle,
Settings,
Tag,
Upload,
} from "lucide-react";
import { Tooltip } from "../ui/Tooltip";
export interface DbViewerSidebarProps {
currentView: string;
onNavigate: (view: string) => void;
currentView: string;
onNavigate: (view: string) => void;
}
interface NavItem {
id: string;
label: string;
icon: React.ReactNode;
stub?: boolean;
id: string;
label: string;
icon: React.ReactNode;
stub?: boolean;
}
export function DbViewerSidebar({ currentView, onNavigate }: DbViewerSidebarProps) {
const topItems: NavItem[] = [
{ id: "db-viewer", label: "Explorer", icon: <Database size={20} /> },
{ id: "schema-visualizer", label: "Schema Visualizer coming soon", icon: <Grid2x2 size={20} />, stub: true },
{ id: "functions", label: "Functions coming soon", icon: <FunctionSquare size={20} />, stub: true },
{ id: "triggers", label: "Triggers coming soon", icon: <GitBranch size={20} />, stub: true },
];
export function DbViewerSidebar({
currentView,
onNavigate,
}: DbViewerSidebarProps) {
const topItems: NavItem[] = [
{ id: "db-viewer", label: "Explorer", icon: <Database size={20} /> },
{
id: "schema-visualizer",
label: "Schema Visualizer coming soon",
icon: <Grid2x2 size={20} />,
stub: true,
},
{
id: "functions",
label: "Functions",
icon: <FunctionSquare size={20} />,
},
{ id: "triggers", label: "Triggers", icon: <GitBranch size={20} /> },
{
id: "sequences",
label: "Sequences",
icon: <ListOrdered size={20} />,
},
{ 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: "sync",
label: "DB Sync",
icon: <ArrowLeftRight size={20} />,
},
];
const bottomItems: NavItem[] = [
{ id: "home", label: "Home", icon: <Home size={20} /> },
{ id: "settings", label: "Settings", icon: <Settings size={20} /> },
];
const bottomItems: NavItem[] = [
{ id: "home", label: "Home", icon: <Home size={20} /> },
{ id: "settings", label: "Settings", icon: <Settings size={20} /> },
];
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";
const activeClass = "text-accent";
const inactiveClass = "text-text-muted hover:text-text hover:bg-surface-raised";
const stubClass = "opacity-40 cursor-not-allowed";
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";
const activeClass = "text-accent";
const inactiveClass =
"text-text-muted hover:text-text hover:bg-surface-raised";
const stubClass = "opacity-40 cursor-not-allowed";
return (
<Tooltip key={item.id} content={item.label} side="right">
<button
type="button"
aria-label={item.label}
disabled={item.stub}
onClick={() => onNavigate(item.id)}
className={`${baseClass} ${isActive ? activeClass : inactiveClass} ${item.stub ? stubClass : ""}`}
>
{item.icon}
</button>
</Tooltip>
);
}
return (
<Tooltip key={item.id} content={item.label} side="right">
<button
type="button"
aria-label={item.label}
disabled={item.stub}
onClick={() => onNavigate(item.id)}
className={`${baseClass} ${isActive ? activeClass : inactiveClass} ${item.stub ? stubClass : ""}`}
>
{item.icon}
</button>
</Tooltip>
<div className="w-14 h-screen bg-canvas border-r border-border flex flex-col items-center py-3 gap-2 shrink-0">
<div className="flex flex-col gap-2 flex-1">
{topItems.map(renderItem)}
</div>
<div className="flex flex-col gap-2">
{bottomItems.map(renderItem)}
</div>
</div>
);
}
return (
<div className="w-14 h-screen bg-canvas border-r border-border flex flex-col items-center py-3 gap-2 shrink-0">
<div className="flex flex-col gap-2 flex-1">{topItems.map(renderItem)}</div>
<div className="flex flex-col gap-2">{bottomItems.map(renderItem)}</div>
</div>
);
}
}
+209 -178
View File
@@ -1,4 +1,12 @@
import { RefreshCw, Plus, Search, Pencil, Check, AlertCircle, X } from "lucide-react";
import {
RefreshCw,
Plus,
Search,
Pencil,
Check,
AlertCircle,
X,
} from "lucide-react";
import { useState, useCallback, useRef, useEffect } from "react";
import { SelectDropdown } from "../ui/SelectDropdown";
import { Tooltip } from "../ui/Tooltip";
@@ -6,190 +14,213 @@ import { useDbViewerStore } from "../../stores/dbViewerStore";
import * as cmd from "../../lib/commands";
export function DbViewerToolbar({
databases,
currentDatabase,
setCurrentDatabase,
schemas,
currentSchema,
setCurrentSchema,
onEdit,
connectionId,
searchQuery,
onSearchChange,
databases,
currentDatabase,
setCurrentDatabase,
schemas,
currentSchema,
setCurrentSchema,
onEdit,
connectionId,
searchQuery,
onSearchChange,
}: {
databases: string[];
currentDatabase: string | null;
setCurrentDatabase: (db: string | null) => void;
schemas: string[];
currentSchema: string | null;
setCurrentSchema: (schema: string | null) => void;
onEdit?: () => void;
connectionId?: string;
searchQuery: string;
onSearchChange: (q: string) => void;
databases: string[];
currentDatabase: string | null;
setCurrentDatabase: (db: string | null) => void;
schemas: string[];
currentSchema: string | null;
setCurrentSchema: (schema: string | null) => void;
onEdit?: () => void;
connectionId?: string;
searchQuery: string;
onSearchChange: (q: string) => void;
}) {
const [searchOpen, setSearchOpen] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [result, setResult] = useState<'idle' | 'success' | 'error'>('idle');
const resultTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const searchContainerRef = useRef<HTMLDivElement>(null);
const populate = useDbViewerStore((s) => s.populate);
const [searchOpen, setSearchOpen] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [result, setResult] = useState<"idle" | "success" | "error">("idle");
const resultTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const searchContainerRef = useRef<HTMLDivElement>(null);
const populate = useDbViewerStore((s) => s.populate);
// Focus input when search opens
useEffect(() => {
if (searchOpen && searchInputRef.current) {
searchInputRef.current.focus();
}
}, [searchOpen]);
// Focus input when search opens
useEffect(() => {
if (searchOpen && searchInputRef.current) {
searchInputRef.current.focus();
}
}, [searchOpen]);
// Auto-hide on blur when empty
const handleSearchBlur = useCallback(() => {
// Small delay to allow clicks on clear button / search icon
setTimeout(() => {
if (!searchQuery.trim()) {
setSearchOpen(false);
}
}, 150);
}, [searchQuery]);
// Auto-hide on blur when empty
const handleSearchBlur = useCallback(() => {
// Small delay to allow clicks on clear button / search icon
setTimeout(() => {
if (!searchQuery.trim()) {
setSearchOpen(false);
}
}, 150);
}, [searchQuery]);
const toggleSearch = useCallback(() => {
setSearchOpen((prev) => {
const next = !prev;
if (!next) onSearchChange(""); // clear when closing
return next;
});
}, [onSearchChange]);
const toggleSearch = useCallback(() => {
setSearchOpen((prev) => {
const next = !prev;
if (!next) onSearchChange(""); // clear when closing
return next;
});
}, [onSearchChange]);
// Cleanup result timer on unmount
useEffect(() => {
return () => { if (resultTimer.current) clearTimeout(resultTimer.current); };
}, []);
// Cleanup result timer on unmount
useEffect(() => {
return () => {
if (resultTimer.current) clearTimeout(resultTimer.current);
};
}, []);
const handleRefresh = useCallback(async () => {
if (!connectionId || refreshing) return;
setRefreshing(true);
setResult('idle');
try {
const dbs = await cmd.getDatabases(connectionId);
const scs = await cmd.getSchemas(connectionId);
const tbls = await cmd.getTables(connectionId);
populate(dbs, scs, tbls);
setResult('success');
} catch {
setResult('error');
} finally {
setRefreshing(false);
resultTimer.current = setTimeout(() => setResult('idle'), 1500);
}
}, [connectionId, refreshing, populate]);
const handleRefresh = useCallback(async () => {
if (!connectionId || refreshing) return;
setRefreshing(true);
setResult("idle");
try {
const dbs = await cmd.getDatabases(connectionId);
const scs = await cmd.getSchemas(connectionId);
const tbls = await cmd.getTables(connectionId);
populate(dbs, scs, tbls);
setResult("success");
} catch {
setResult("error");
} finally {
setRefreshing(false);
resultTimer.current = setTimeout(() => setResult("idle"), 1500);
}
}, [connectionId, refreshing, populate]);
return (
<div className="p-3 border-b border-border space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-text">Tables</span>
<div className="flex items-center gap-1">
{onEdit && (
<Tooltip content="Edit Connection" side="bottom">
<button
aria-label="Edit Connection"
onClick={onEdit}
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer"
>
<Pencil size={14} />
</button>
</Tooltip>
)}
<Tooltip content={result === 'success' ? 'Refreshed' : result === 'error' ? 'Refresh failed' : 'Refresh Database'} side="bottom">
<button
aria-label="Refresh"
onClick={handleRefresh}
disabled={refreshing}
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer disabled:opacity-50"
return (
<div className="p-3 border-b border-border space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-text">Tables</span>
<div className="flex items-center gap-1">
{onEdit && (
<Tooltip content="Edit Connection" side="bottom">
<button
aria-label="Edit Connection"
onClick={onEdit}
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer"
>
<Pencil size={14} />
</button>
</Tooltip>
)}
<Tooltip
content={
result === "success"
? "Refreshed"
: result === "error"
? "Refresh failed"
: "Refresh Database"
}
side="bottom"
>
<button
aria-label="Refresh"
onClick={handleRefresh}
disabled={refreshing}
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer disabled:opacity-50"
>
{refreshing ? (
<RefreshCw size={14} className="animate-spin" />
) : result === "success" ? (
<Check size={14} className="text-emerald-400" />
) : result === "error" ? (
<AlertCircle
size={14}
className="text-red-400"
/>
) : (
<RefreshCw size={14} />
)}
</button>
</Tooltip>
<Tooltip content="Create Table" side="bottom">
<button
aria-label="Create Table"
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer opacity-50"
>
<Plus size={14} />
</button>
</Tooltip>
<Tooltip content="Search Tables" side="bottom">
<button
aria-label="Search Tables"
onClick={toggleSearch}
className={`w-7 h-7 rounded-md flex items-center justify-center cursor-pointer ${searchOpen ? "text-accent bg-accent/10" : "text-text-muted hover:text-text hover:bg-surface-raised"}`}
>
<Search size={14} />
</button>
</Tooltip>
</div>
</div>
{/* Search input */}
<div
ref={searchContainerRef}
className={`overflow-hidden transition-all duration-200 ease-out ${searchOpen ? "max-h-10 opacity-100" : "max-h-0 opacity-0"}`}
>
{refreshing ? (
<RefreshCw size={14} className="animate-spin" />
) : result === 'success' ? (
<Check size={14} className="text-emerald-400" />
) : result === 'error' ? (
<AlertCircle size={14} className="text-red-400" />
) : (
<RefreshCw size={14} />
)}
</button>
</Tooltip>
<Tooltip content="Create Table" side="bottom">
<button
aria-label="Create Table"
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer opacity-50"
>
<Plus size={14} />
</button>
</Tooltip>
<Tooltip content="Search Tables" side="bottom">
<button
aria-label="Search Tables"
onClick={toggleSearch}
className={`w-7 h-7 rounded-md flex items-center justify-center cursor-pointer ${searchOpen ? "text-accent bg-accent/10" : "text-text-muted hover:text-text hover:bg-surface-raised"}`}
>
<Search size={14} />
</button>
</Tooltip>
<div className="relative flex items-center">
<Search
size={12}
className="absolute left-2.5 text-text-muted pointer-events-none"
/>
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
onBlur={handleSearchBlur}
placeholder="Filter tables…"
className="w-full bg-transparent border-0 border-b border-border pl-8 pr-7 py-1.5 text-xs text-text placeholder:text-text-muted/60 outline-none focus:border-accent/50 transition-colors"
/>
{searchQuery && (
<button
onClick={() => onSearchChange("")}
className="absolute right-1 flex items-center justify-center w-5 h-5 rounded text-text-muted hover:text-text cursor-pointer"
>
<X size={12} />
</button>
)}
</div>
</div>
{(databases.length > 1 || schemas.length > 1) && (
<div className="flex items-center gap-2">
{databases.length > 1 && (
<SelectDropdown
value={currentDatabase ?? ""}
onChange={setCurrentDatabase}
options={databases.map((d) => ({
value: d,
label: d,
}))}
placeholder="Select database"
aria-label="Select database"
variant="ghost"
/>
)}
{databases.length > 1 && schemas.length > 1 && (
<span className="text-border">|</span>
)}
{schemas.length > 1 && (
<SelectDropdown
value={currentSchema ?? ""}
onChange={setCurrentSchema}
options={schemas.map((s) => ({
value: s,
label: s,
}))}
placeholder="Select schema"
aria-label="Select schema"
variant="ghost"
/>
)}
</div>
)}
</div>
</div>
{/* Search input */}
<div
ref={searchContainerRef}
className={`overflow-hidden transition-all duration-200 ease-out ${searchOpen ? "max-h-10 opacity-100" : "max-h-0 opacity-0"}`}
>
<div className="relative flex items-center">
<Search size={12} className="absolute left-2.5 text-text-muted pointer-events-none" />
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
onBlur={handleSearchBlur}
placeholder="Filter tables…"
className="w-full bg-transparent border-0 border-b border-border pl-8 pr-7 py-1.5 text-xs text-text placeholder:text-text-muted/60 outline-none focus:border-accent/50 transition-colors"
/>
{searchQuery && (
<button
onClick={() => onSearchChange("")}
className="absolute right-1 flex items-center justify-center w-5 h-5 rounded text-text-muted hover:text-text cursor-pointer"
>
<X size={12} />
</button>
)}
</div>
</div>
{(databases.length > 1 || schemas.length > 1) && (
<div className="flex items-center gap-2">
{databases.length > 1 && (
<SelectDropdown
value={currentDatabase ?? ""}
onChange={setCurrentDatabase}
options={databases.map((d) => ({ value: d, label: d }))}
placeholder="Select database"
aria-label="Select database"
variant="ghost"
/>
)}
{databases.length > 1 && schemas.length > 1 && (
<span className="text-border">|</span>
)}
{schemas.length > 1 && (
<SelectDropdown
value={currentSchema ?? ""}
onChange={setCurrentSchema}
options={schemas.map((s) => ({ value: s, label: s }))}
placeholder="Select schema"
aria-label="Select schema"
variant="ghost"
/>
)}
</div>
)}
</div>
);
}
);
}
File diff suppressed because it is too large Load Diff
+329
View File
@@ -0,0 +1,329 @@
import { useEffect, useState, useMemo } from "react";
import { ChevronRight, ChevronDown, FunctionSquare, GitBranch, ListOrdered, Tag, Puzzle, Search } from "lucide-react";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import * as cmd from "../../lib/commands";
import type { FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo } from "../../lib/types";
type ObjectType = "functions" | "triggers" | "sequences" | "enums" | "extensions";
interface ObjectTreeProps {
type: ObjectType;
connectionId: string;
}
const TYPE_LABELS: Record<ObjectType, string> = {
functions: "functions",
triggers: "triggers",
sequences: "sequences",
enums: "enums",
extensions: "extensions",
};
const ICONS: Record<ObjectType, React.ReactNode> = {
functions: <FunctionSquare size={14} className="text-text-muted shrink-0" />,
triggers: <GitBranch size={14} className="text-text-muted shrink-0" />,
sequences: <ListOrdered size={14} className="text-text-muted shrink-0" />,
enums: <Tag size={14} className="text-text-muted shrink-0" />,
extensions: <Puzzle size={14} className="text-text-muted shrink-0" />,
};
function SourceCode({ source }: { source: string }) {
const [expanded, setExpanded] = useState(false);
const maxLen = 500;
const truncated = source.length > maxLen && !expanded;
const display = truncated ? source.slice(0, maxLen) : source;
return (
<div className="mt-1">
<pre className="text-xs text-text-muted bg-surface-raised rounded p-2 overflow-x-auto whitespace-pre-wrap font-mono">
{display}
{truncated && <span className="text-text-subtle">...</span>}
</pre>
{source.length > maxLen && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); setExpanded((v) => !v); }}
className="text-xs text-accent hover:underline mt-1"
>
{expanded ? "Show less" : "Show more"}
</button>
)}
</div>
);
}
export function ObjectTree({ type, connectionId }: ObjectTreeProps) {
const currentSchema = useDbViewerStore((s) => s.currentSchema);
const functions = useDbViewerStore((s) => s.functions);
const triggers = useDbViewerStore((s) => s.triggers);
const sequences = useDbViewerStore((s) => s.sequences);
const enums = useDbViewerStore((s) => s.enums);
const extensions = useDbViewerStore((s) => s.extensions);
const setFunctions = useDbViewerStore((s) => s.setFunctions);
const setTriggers = useDbViewerStore((s) => s.setTriggers);
const setSequences = useDbViewerStore((s) => s.setSequences);
const setEnums = useDbViewerStore((s) => s.setEnums);
const setExtensions = useDbViewerStore((s) => s.setExtensions);
const [loading, setLoading] = useState(false);
const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set());
const [search, setSearch] = useState("");
// Determine which store accessors to use
const data = useMemo(() => {
switch (type) {
case "functions": return functions;
case "triggers": return triggers;
case "sequences": return sequences;
case "enums": return enums;
case "extensions": return extensions;
}
}, [type, functions, triggers, sequences, enums, extensions]);
const setter = useMemo(() => {
switch (type) {
case "functions": return setFunctions;
case "triggers": return setTriggers;
case "sequences": return setSequences;
case "enums": return setEnums;
case "extensions": return setExtensions;
}
}, [type, setFunctions, setTriggers, setSequences, setEnums, setExtensions]);
// Fetch on mount if not in store
useEffect(() => {
if (data !== null) return;
let cancelled = false;
setLoading(true);
const fetchData = async () => {
try {
if (type === "extensions") {
const result = await cmd.getExtensions(connectionId);
if (!cancelled) (setExtensions as (v: ExtensionInfo[]) => void)(result);
} else if (type === "functions") {
const result = await cmd.getFunctions(connectionId, currentSchema ?? undefined);
if (!cancelled) (setFunctions as (v: FunctionInfo[]) => void)(result);
} else if (type === "triggers") {
const result = await cmd.getTriggers(connectionId, currentSchema ?? undefined);
if (!cancelled) (setTriggers as (v: TriggerInfo[]) => void)(result);
} else if (type === "sequences") {
const result = await cmd.getSequences(connectionId, currentSchema ?? undefined);
if (!cancelled) (setSequences as (v: SequenceInfo[]) => void)(result);
} else if (type === "enums") {
const result = await cmd.getEnums(connectionId, currentSchema ?? undefined);
if (!cancelled) (setEnums as (v: EnumInfo[]) => void)(result);
}
} catch {
// Silently fail — store remains null, we show the empty state
} finally {
if (!cancelled) setLoading(false);
}
};
fetchData();
return () => { cancelled = true; };
}, [type, connectionId, currentSchema, data, setter, setFunctions, setTriggers, setSequences, setEnums, setExtensions]);
const q = search.toLowerCase().trim();
const list = useMemo(() => {
if (!data) return [];
if (!q) return data;
return data.filter((item) => {
const name = "name" in item ? (item as { name: string }).name : "";
return name.toLowerCase().includes(q);
});
}, [data, q]);
const toggle = (key: string) => {
setExpandedKeys((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const icon = ICONS[type];
const pluralLabel = TYPE_LABELS[type];
return (
<div className="flex flex-col h-full">
{/* Search bar */}
<div className="px-3 py-2 border-b border-border">
<div className="relative">
<Search size={14} className="absolute left-2 top-1/2 -translate-y-1/2 text-text-muted" />
<input
type="text"
placeholder={`Search ${pluralLabel}...`}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-7 pr-2 py-1 text-xs bg-surface-raised border border-border rounded text-text placeholder:text-text-subtle focus:outline-none focus:border-accent/50"
/>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto py-2" style={{ overscrollBehavior: "none" }}>
{loading && (
<div className="flex items-center justify-center py-8 text-sm text-text-muted">
<div className="w-4 h-4 border-2 border-text-muted border-t-accent rounded-full animate-spin mr-2" />
Loading {pluralLabel}...
</div>
)}
{!loading && list.length === 0 && (
<div className="px-3 py-2 text-sm text-text-muted">
{data === null ? `No ${pluralLabel} found` : `No ${pluralLabel} found`}
</div>
)}
{!loading && list.map((item) => {
const name = "name" in item ? (item as { name: string }).name : "";
const key = name;
const isExpanded = expandedKeys.has(key);
return (
<div key={key}>
<div
className="group flex items-center gap-1 px-3 py-1 hover:bg-surface-raised cursor-pointer"
onClick={() => toggle(key)}
>
<button
type="button"
aria-label={isExpanded ? "Collapse" : "Expand"}
className="w-5 h-5 flex items-center justify-center text-text-muted hover:text-text cursor-pointer"
>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
{icon}
<span className="flex-1 text-left text-sm text-text group-hover:text-accent truncate">
{name}
</span>
</div>
{isExpanded && (
<div className="pl-10 pr-3 py-1 space-y-1">
{type === "functions" && (() => {
const f = item as FunctionInfo;
return (
<>
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Returns:</span> {f.return_type}
</div>
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Language:</span> {f.language}
</div>
{f.argument_names.length > 0 && (
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Args:</span>{" "}
{f.argument_names.map((a, i) => (
<span key={i}>
{f.argument_modes?.[i] && f.argument_modes[i] !== "IN" && (
<span className="text-amber-400">{f.argument_modes[i]} </span>
)}
{a} <span className="text-text-subtle">({f.argument_types?.[i] || "unknown"})</span>
{i < f.argument_names.length - 1 && ", "}
</span>
))}
</div>
)}
{f.source && <SourceCode source={f.source} />}
</>
);
})()}
{type === "triggers" && (() => {
const t = item as TriggerInfo;
return (
<>
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Table:</span> {t.table_schema}.{t.table_name}
</div>
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Event:</span> {t.event_manipulation}
</div>
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Timing:</span> {t.action_timing}
</div>
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Orientation:</span> {t.action_orientation}
</div>
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Enabled:</span> {t.enabled}
</div>
{t.action_statement && <SourceCode source={t.action_statement} />}
</>
);
})()}
{type === "sequences" && (() => {
const s = item as SequenceInfo;
return (
<>
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Current:</span> {s.current_value}
</div>
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Increment:</span> {s.increment}
</div>
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Min:</span> {s.min_value}
</div>
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Max:</span> {s.max_value}
</div>
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Start:</span> {s.start_value}
</div>
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Cycle:</span> {s.cycle ? "Yes" : "No"}
</div>
</>
);
})()}
{type === "enums" && (() => {
const e = item as EnumInfo;
return (
<div className="flex flex-wrap gap-1 mt-1">
{e.labels.map((label) => (
<span
key={label}
className="inline-block px-1.5 py-0.5 text-[10px] rounded bg-surface-raised text-text-muted border border-border"
>
{label}
</span>
))}
</div>
);
})()}
{type === "extensions" && (() => {
const e = item as ExtensionInfo;
return (
<>
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Version:</span> {e.version}
</div>
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Schema:</span> {e.schema}
</div>
{e.comment && (
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Comment:</span> {e.comment}
</div>
)}
</>
);
})()}
</div>
)}
</div>
);
})}
</div>
</div>
);
}
+229
View File
@@ -0,0 +1,229 @@
import { useState, useEffect, useCallback } from "react";
import { AnimatedModal } from "../ui/AnimatedModal";
import { Button } from "../ui/Button";
import { BackupProgress } from "./BackupProgress";
import { useBackupStore } from "../../stores/backupStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { detectPgTools, pgRestore } from "../../lib/commands";
import type { PgToolStatus } from "../../lib/types";
interface RestoreDialogProps {
open: boolean;
connectionId: string;
onClose: () => void;
}
const PLATFORM_INSTALL_INSTRUCTIONS: Record<string, string> = {
darwin: "brew install libpq",
linux: "sudo apt install postgresql-client # Debian/Ubuntu\nsudo dnf install postgresql # Fedora\nsudo pacman -S postgresql # Arch",
win32: "Download PostgreSQL installer from https://www.postgresql.org/download/windows/ and ensure pg_restore is in your PATH.",
};
function getPlatformInstructions(): string {
const platform = typeof navigator !== "undefined" ? navigator.platform.toLowerCase() : "";
if (platform.includes("mac") || platform.includes("darwin")) return PLATFORM_INSTALL_INSTRUCTIONS.darwin;
if (platform.includes("linux")) return PLATFORM_INSTALL_INSTRUCTIONS.linux;
if (platform.includes("win")) return PLATFORM_INSTALL_INSTRUCTIONS.win32;
return PLATFORM_INSTALL_INSTRUCTIONS.linux;
}
export function RestoreDialog({ open, connectionId, onClose }: RestoreDialogProps) {
const [filePath, setFilePath] = useState("");
const [format, setFormat] = useState("custom");
const [clean, setClean] = useState(true);
const [schema, setSchema] = useState("");
const [confirmed, setConfirmed] = useState(false);
const [toolStatus, setToolStatus] = useState<PgToolStatus | null>(null);
const [checkingTools, setCheckingTools] = useState(false);
const [running, setRunning] = useState(false);
const activeJobId = useBackupStore((s) => s.activeJobId);
const jobs = useBackupStore((s) => s.jobs);
const startJob = useBackupStore((s) => s.startJob);
const notify = useNotificationStore((s) => s.notify);
const activeJob = jobs.find((j) => j.id === activeJobId);
useEffect(() => {
if (!open) return;
setCheckingTools(true);
setConfirmed(false);
detectPgTools()
.then((status) => setToolStatus(status))
.catch(() => setToolStatus({ pg_dump_found: false, pg_restore_found: false, pg_dump_version: null, pg_restore_version: null }))
.finally(() => setCheckingTools(false));
}, [open]);
const handlePickFile = useCallback(async () => {
try {
const { open: openDialog } = await import("@tauri-apps/plugin-dialog");
const picked = await openDialog({
multiple: false,
filters: [{ name: "Backup Files", extensions: ["dump", "sql", "tar", "custom", "gz"] }],
});
if (picked && typeof picked === "string") setFilePath(picked);
} catch {
// dialog not available (non-Tauri env), use manual path input
}
}, []);
const handleStartRestore = useCallback(async () => {
if (!filePath) {
notify("Please select a file path", "error");
return;
}
setRunning(true);
const jobId = `restore-${Date.now()}`;
startJob(jobId, "restore");
try {
await pgRestore(connectionId, {
format,
filePath,
clean,
schema: schema || undefined,
});
notify("Restore completed successfully", "success");
onClose();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
notify(`Restore failed: ${parseError(msg)}`, "error");
} finally {
setRunning(false);
}
}, [filePath, format, clean, schema, connectionId, startJob, notify, onClose]);
const toolsMissing = toolStatus && !toolStatus.pg_restore_found;
const canStart = filePath && confirmed && !running;
return (
<AnimatedModal open={open} onClose={onClose}>
<div className="w-full min-w-md max-w-lg max-h-[80vh] overflow-y-auto">
<h3 className="font-heading text-text text-lg mb-4">Restore Database</h3>
{checkingTools && (
<p className="text-sm text-text-muted mb-4">Checking for pg_restore...</p>
)}
{toolsMissing && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-md px-4 py-3 mb-4 space-y-2">
<p className="text-amber-300 text-sm font-medium">pg_restore not found</p>
<p className="text-amber-200/80 text-xs">
The PostgreSQL client tools are required for backup/restore operations. Install them using:
</p>
<pre className="text-xs text-amber-100 bg-amber-500/10 rounded p-2 whitespace-pre-wrap">
{getPlatformInstructions()}
</pre>
</div>
)}
{!checkingTools && !toolsMissing && (
<div className="space-y-4">
{/* File path */}
<div className="space-y-1">
<label className="text-xs text-text-muted">Backup File</label>
<div className="flex gap-2">
<input
type="text"
value={filePath}
onChange={(e) => setFilePath(e.target.value)}
placeholder="/path/to/backup.dump"
className="flex-1 rounded-full bg-surface border border-border px-4 py-2 text-sm text-text placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors"
/>
<Button variant="secondary" onClick={handlePickFile}>
Browse
</Button>
</div>
</div>
{/* Format */}
<div className="space-y-1">
<label className="text-xs text-text-muted">Format</label>
<select
value={format}
onChange={(e) => setFormat(e.target.value)}
className="rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
>
<option value="custom">Custom Archive</option>
<option value="plain">Plain SQL</option>
<option value="tar">Tarball</option>
<option value="directory">Directory</option>
</select>
</div>
{/* Schema filter */}
<div className="space-y-1">
<label className="text-xs text-text-muted">Schema (optional)</label>
<input
type="text"
value={schema}
onChange={(e) => setSchema(e.target.value)}
placeholder="public"
className="w-full rounded-full bg-surface border border-border px-4 py-2 text-sm text-text placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors"
/>
</div>
{/* Clean toggle */}
<label className="flex items-center gap-2 text-sm text-text-muted cursor-pointer">
<input
type="checkbox"
checked={clean}
onChange={(e) => setClean(e.target.checked)}
className="rounded bg-surface border-border accent-accent"
/>
Clean (DROP before CREATE)
</label>
{/* Destructive confirmation */}
<div className="bg-red-500/5 border border-red-500/20 rounded-lg px-4 py-3">
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={confirmed}
onChange={(e) => setConfirmed(e.target.checked)}
className="mt-0.5 rounded bg-surface border-border accent-red-500"
data-testid="restore-confirm-checkbox"
/>
<span className="text-sm text-red-300">
I understand this will overwrite data on the target database. This action cannot be undone.
</span>
</label>
</div>
{/* Progress */}
{activeJob?.status === "running" && (
<BackupProgress
progress={50}
jobType="restore"
status="running"
/>
)}
{/* Actions */}
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={onClose} disabled={running}>
Cancel
</Button>
<Button onClick={handleStartRestore} disabled={!canStart}>
{running ? "Restoring..." : "Start Restore"}
</Button>
</div>
</div>
)}
</div>
</AnimatedModal>
);
}
function parseError(msg: string): string {
if (msg.includes("pg_restore:")) {
const [, ...rest] = msg.split("pg_restore:");
return rest.join(":").trim() || msg;
}
if (msg.includes("No such file or directory")) {
return `File not found. Check the path and try again.`;
}
if (msg.includes("Permission denied")) {
return `Permission denied. Check file permissions.`;
}
return msg;
}
+311
View File
@@ -0,0 +1,311 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { FileSearch, Upload } from "lucide-react";
import { open } from "@tauri-apps/plugin-dialog";
import { Button } from "../ui/Button";
import { BackupProgress } from "./BackupProgress";
import { useBackupStore } from "../../stores/backupStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { detectPgTools, pgRestore, getSchemas } from "../../lib/commands";
import type { PgToolStatus } from "../../lib/types";
interface RestorePageProps {
connectionId: string;
}
const PLATFORM_INSTALL_INSTRUCTIONS: Record<string, string> = {
darwin: "brew install libpq",
linux: "sudo apt install postgresql-client # Debian/Ubuntu\nsudo dnf install postgresql # Fedora\nsudo pacman -S postgresql # Arch",
win32: "Download PostgreSQL installer from https://www.postgresql.org/download/windows/ and ensure pg_restore is in your PATH.",
};
function getPlatformInstructions(): string {
const platform =
typeof navigator !== "undefined"
? navigator.platform.toLowerCase()
: "";
if (platform.includes("mac") || platform.includes("darwin"))
return PLATFORM_INSTALL_INSTRUCTIONS.darwin;
if (platform.includes("linux")) return PLATFORM_INSTALL_INSTRUCTIONS.linux;
if (platform.includes("win")) return PLATFORM_INSTALL_INSTRUCTIONS.win32;
return PLATFORM_INSTALL_INSTRUCTIONS.linux;
}
export function RestorePage({ connectionId }: RestorePageProps) {
const [filePath, setFilePath] = useState("");
const [format, setFormat] = useState("custom");
const [clean, setClean] = useState(true);
const [schema, setSchema] = useState("");
const [confirmed, setConfirmed] = useState(false);
const [toolStatus, setToolStatus] = useState<PgToolStatus | null>(null);
const [checkingTools, setCheckingTools] = useState(true);
const [availableSchemas, setAvailableSchemas] = useState<string[]>([]);
const activeJobId = useBackupStore((s) => s.activeJobId);
const jobs = useBackupStore((s) => s.jobs);
const startJob = useBackupStore((s) => s.startJob);
const notify = useNotificationStore((s) => s.notify);
const activeJob = jobs.find((j) => j.id === activeJobId);
const isRunning = activeJob?.status === "running";
const pendingJobRef = useRef<string | null>(null);
useEffect(() => {
if (!pendingJobRef.current || !activeJob) return;
if (activeJob.id !== pendingJobRef.current) return;
if (activeJob.status === "completed") {
notify("Restore completed successfully", "success");
pendingJobRef.current = null;
} else if (activeJob.status === "failed") {
notify(
`Restore failed: ${activeJob.error_message || "Unknown error"}`,
"error",
);
pendingJobRef.current = null;
}
}, [activeJob, notify]);
useEffect(() => {
setCheckingTools(true);
setConfirmed(false);
detectPgTools()
.then((status) => setToolStatus(status))
.catch(() =>
setToolStatus({
pg_dump_found: false,
pg_restore_found: false,
pg_dump_version: null,
pg_restore_version: null,
}),
)
.finally(() => setCheckingTools(false));
getSchemas(connectionId)
.then((schemas) => setAvailableSchemas(schemas))
.catch(() => setAvailableSchemas([]));
}, [connectionId]);
const handlePickFile = useCallback(async () => {
const picked = await open({
multiple: false,
filters: [
{
name: "Backup Files",
extensions: ["dump", "sql", "tar", "custom", "gz"],
},
],
});
if (picked && typeof picked === "string") setFilePath(picked);
}, []);
const handleStartRestore = useCallback(async () => {
if (!filePath) {
notify("Please select a file path", "error");
return;
}
const jobId = `restore-${Date.now()}`;
startJob(jobId, "restore");
pendingJobRef.current = jobId;
try {
await pgRestore(connectionId, {
format,
filePath,
clean,
schema: schema || undefined,
});
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
useBackupStore.getState().failJob(jobId, msg);
}
}, [filePath, format, clean, schema, connectionId, startJob, notify]);
const toolsMissing = toolStatus && !toolStatus.pg_restore_found;
const canStart = filePath && confirmed && !isRunning;
return (
<div className="flex flex-col h-full">
{/* Toolbar header */}
<div className="flex items-center gap-2 border-b border-border px-3 py-1.5">
<Upload size={14} className="text-accent" />
<span className="text-xs font-medium text-text">Restore</span>
<span className="text-[11px] text-text-muted">
Restore a database from a backup file
</span>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
<div className="max-w-lg mx-auto space-y-6 outline outline-border">
{/* Tool check */}
{checkingTools && (
<div className="glass p-4 text-center">
<p className="text-sm text-text-muted">
Checking for pg_restore...
</p>
</div>
)}
{toolsMissing && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2">
<p className="text-amber-300 text-sm font-semibold">
pg_restore not found
</p>
<p className="text-amber-200/80 text-xs leading-relaxed">
The PostgreSQL client tools are required for
backup/restore operations. Install them using:
</p>
<pre className="text-xs text-amber-100 bg-amber-500/10 rounded-lg p-3 whitespace-pre-wrap font-mono leading-relaxed">
{getPlatformInstructions()}
</pre>
</div>
)}
{!checkingTools && !toolsMissing && (
<>
{/* Configuration card */}
<div className="p-5 space-y-5">
{/* Format */}
<div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Format
</label>
<select
value={format}
onChange={(e) =>
setFormat(e.target.value)
}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
>
<option value="custom">
Custom Archive
</option>
<option value="plain">Plain SQL</option>
<option value="tar">Tarball</option>
<option value="directory">
Directory
</option>
</select>
</div>
{/* Backup file */}
<div className="space-y-1 w-full">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Backup File
</label>
<div className="flex gap-2">
<input
type="text"
value={filePath}
onChange={(e) =>
setFilePath(e.target.value)
}
placeholder="/path/to/backup.dump"
className="flex-1 px-4 py-2 text-sm text-text placeholder-text-muted/50 border-b border-border focus:border-accent focus:outline-none transition-colors"
/>
<button
type="button"
onClick={handlePickFile}
className="flex items-center justify-center w-9 h-9 rounded-lg border border-border bg-surface text-text-muted hover:text-text hover:bg-surface-raised hover:border-border-hover transition-colors cursor-pointer shrink-0"
aria-label="Browse for file"
>
<FileSearch size={15} />
</button>
</div>
</div>
{/* Schema (optional) */}
<div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Schema{" "}
<span className="font-normal normal-case tracking-normal">
(optional)
</span>
</label>
<select
value={schema}
onChange={(e) =>
setSchema(e.target.value)
}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
>
<option value="">All schemas</option>
{availableSchemas.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
{/* Clean toggle */}
<label className="flex items-center gap-2.5 cursor-pointer group">
<input
type="checkbox"
checked={clean}
onChange={(e) =>
setClean(e.target.checked)
}
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
/>
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
Clean{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
DROP before CREATE
</code>
</span>
</label>
</div>
{/* Destructive confirmation */}
<div className="bg-red-500/5 border border-red-500/20 px-4 py-3">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={confirmed}
onChange={(e) =>
setConfirmed(e.target.checked)
}
className="mt-0.5 rounded bg-surface border-border accent-red-500 w-4 h-4 cursor-pointer"
data-testid="restore-confirm-checkbox"
/>
<span className="text-sm text-red-300/90 leading-relaxed">
I understand this will overwrite data on
the target database. This action cannot
be undone.
</span>
</label>
</div>
{/* Progress */}
{activeJob && (
<div className="px-4">
<BackupProgress
progress={activeJob.status === "completed" ? 100 : 50}
jobType="restore"
status={activeJob.status}
errorMessage={activeJob.error_message ?? undefined}
/>
</div>
)}
{/* Actions */}
<div className="flex justify-end pb-2 pr-2">
<Button
onClick={handleStartRestore}
disabled={!canStart}
>
<Upload size={14} className="mr-1.5" />
{isRunning
? "Restoring..."
: "Start Restore"}
</Button>
</div>
</>
)}
</div>
</div>
</div>
);
}
+202
View File
@@ -0,0 +1,202 @@
import { useState, useEffect, useCallback } from "react";
import { AnimatedModal } from "../ui/AnimatedModal";
import { Button } from "../ui/Button";
import { BackupProgress } from "./BackupProgress";
import { useBackupStore } from "../../stores/backupStore";
import { useConnectionStore } from "../../stores/connectionStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { detectPgTools, dbSync } from "../../lib/commands";
import type { PgToolStatus } from "../../lib/types";
interface SyncDialogProps {
open: boolean;
onClose: () => void;
}
export function SyncDialog({ open, onClose }: SyncDialogProps) {
const [sourceConnectionId, setSourceConnectionId] = useState("");
const [targetConnectionId, setTargetConnectionId] = useState("");
const [schema, setSchema] = useState("");
const [confirmed, setConfirmed] = useState(false);
const [toolStatus, setToolStatus] = useState<PgToolStatus | null>(null);
const [checkingTools, setCheckingTools] = useState(false);
const [running, setRunning] = useState(false);
const connections = useConnectionStore((s) => s.connections);
const activeJobId = useBackupStore((s) => s.activeJobId);
const jobs = useBackupStore((s) => s.jobs);
const startJob = useBackupStore((s) => s.startJob);
const notify = useNotificationStore((s) => s.notify);
const activeJob = jobs.find((j) => j.id === activeJobId);
useEffect(() => {
if (!open) return;
setCheckingTools(true);
setConfirmed(false);
detectPgTools()
.then((status) => setToolStatus(status))
.catch(() => setToolStatus({ pg_dump_found: false, pg_restore_found: false, pg_dump_version: null, pg_restore_version: null }))
.finally(() => setCheckingTools(false));
}, [open]);
const handleStartSync = useCallback(async () => {
if (!sourceConnectionId || !targetConnectionId) {
notify("Please select both source and target connections", "error");
return;
}
if (sourceConnectionId === targetConnectionId) {
notify("Source and target must be different", "error");
return;
}
setRunning(true);
const jobId = `sync-${Date.now()}`;
startJob(jobId, "sync");
try {
await dbSync({
sourceConnectionId,
targetConnectionId,
schema: schema || undefined,
tables: undefined,
});
notify("Sync completed successfully", "success");
onClose();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
notify(`Sync failed: ${parseError(msg)}`, "error");
} finally {
setRunning(false);
}
}, [sourceConnectionId, targetConnectionId, schema, startJob, notify, onClose]);
const toolsMissing = toolStatus && (!toolStatus.pg_dump_found || !toolStatus.pg_restore_found);
const canStart = sourceConnectionId && targetConnectionId && confirmed && !running;
const postgresqlConnections = connections.filter((c) => c.db_type === "postgresql");
return (
<AnimatedModal open={open} onClose={onClose}>
<div className="w-full min-w-md max-w-lg max-h-[80vh] overflow-y-auto">
<h3 className="font-heading text-text text-lg mb-4">Sync Databases</h3>
{checkingTools && (
<p className="text-sm text-text-muted mb-4">Checking for pg_dump/pg_restore...</p>
)}
{toolsMissing && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-md px-4 py-3 mb-4 space-y-2">
<p className="text-amber-300 text-sm font-medium">PostgreSQL tools not found</p>
<p className="text-amber-200/80 text-xs">
Both pg_dump and pg_restore are required for database sync.
</p>
{!toolStatus?.pg_dump_found && (
<p className="text-amber-200/80 text-xs">pg_dump is missing.</p>
)}
{!toolStatus?.pg_restore_found && (
<p className="text-amber-200/80 text-xs">pg_restore is missing.</p>
)}
</div>
)}
{!checkingTools && !toolsMissing && (
<div className="space-y-4">
{/* Source connection */}
<div className="space-y-1">
<label className="text-xs text-text-muted">Source Connection</label>
<select
value={sourceConnectionId}
onChange={(e) => setSourceConnectionId(e.target.value)}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
>
<option value="">Select source...</option>
{postgresqlConnections.map((c) => (
<option key={c.id} value={c.id} disabled={c.id === targetConnectionId}>
{c.name}
</option>
))}
</select>
</div>
{/* Target connection */}
<div className="space-y-1">
<label className="text-xs text-text-muted">Target Connection</label>
<select
value={targetConnectionId}
onChange={(e) => setTargetConnectionId(e.target.value)}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
>
<option value="">Select target...</option>
{postgresqlConnections.map((c) => (
<option key={c.id} value={c.id} disabled={c.id === sourceConnectionId}>
{c.name}
</option>
))}
</select>
</div>
{/* Schema filter */}
<div className="space-y-1">
<label className="text-xs text-text-muted">Schema (optional)</label>
<input
type="text"
value={schema}
onChange={(e) => setSchema(e.target.value)}
placeholder="public"
className="w-full rounded-full bg-surface border border-border px-4 py-2 text-sm text-text placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors"
/>
</div>
{/* Destructive confirmation */}
<div className="bg-red-500/5 border border-red-500/20 rounded-lg px-4 py-3">
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={confirmed}
onChange={(e) => setConfirmed(e.target.checked)}
className="mt-0.5 rounded bg-surface border-border accent-red-500"
data-testid="sync-confirm-checkbox"
/>
<span className="text-sm text-red-300">
I understand this will overwrite data on the target database. This action cannot be undone.
</span>
</label>
</div>
{/* Progress */}
{activeJob?.status === "running" && (
<BackupProgress
progress={50}
jobType="sync"
status="running"
/>
)}
{/* Actions */}
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={onClose} disabled={running}>
Cancel
</Button>
<Button onClick={handleStartSync} disabled={!canStart}>
{running ? "Syncing..." : "Start Sync"}
</Button>
</div>
</div>
)}
</div>
</AnimatedModal>
);
}
function parseError(msg: string): string {
if (msg.includes("pg_dump:") || msg.includes("pg_restore:")) {
const parts = msg.split(/pg_(dump|restore):/);
return parts[parts.length - 1]?.trim() || msg;
}
if (msg.includes("No such file or directory")) {
return `File not found. Check the output path and try again.`;
}
if (msg.includes("Permission denied")) {
return `Permission denied. Check file permissions.`;
}
return msg;
}
+327
View File
@@ -0,0 +1,327 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { ArrowLeftRight, Database } from "lucide-react";
import { Button } from "../ui/Button";
import { BackupProgress } from "./BackupProgress";
import { useBackupStore } from "../../stores/backupStore";
import { useConnectionStore } from "../../stores/connectionStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { detectPgTools, dbSync, getSchemas } from "../../lib/commands";
import type { PgToolStatus } from "../../lib/types";
export function SyncPage() {
const [sourceConnectionId, setSourceConnectionId] = useState("");
const [targetConnectionId, setTargetConnectionId] = useState("");
const [schema, setSchema] = useState("");
const [confirmed, setConfirmed] = useState(false);
const [toolStatus, setToolStatus] = useState<PgToolStatus | null>(null);
const [checkingTools, setCheckingTools] = useState(true);
const [availableSchemas, setAvailableSchemas] = useState<string[]>([]);
const connections = useConnectionStore((s) => s.connections);
const activeJobId = useBackupStore((s) => s.activeJobId);
const jobs = useBackupStore((s) => s.jobs);
const startJob = useBackupStore((s) => s.startJob);
const notify = useNotificationStore((s) => s.notify);
const activeJob = jobs.find((j) => j.id === activeJobId);
const isRunning = activeJob?.status === "running";
const pendingJobRef = useRef<string | null>(null);
useEffect(() => {
if (!pendingJobRef.current || !activeJob) return;
if (activeJob.id !== pendingJobRef.current) return;
if (activeJob.status === "completed") {
notify("Sync completed successfully", "success");
pendingJobRef.current = null;
} else if (activeJob.status === "failed") {
notify(
`Sync failed: ${activeJob.error_message || "Unknown error"}`,
"error",
);
pendingJobRef.current = null;
}
}, [activeJob, notify]);
useEffect(() => {
setCheckingTools(true);
setConfirmed(false);
detectPgTools()
.then((status) => setToolStatus(status))
.catch(() =>
setToolStatus({
pg_dump_found: false,
pg_restore_found: false,
pg_dump_version: null,
pg_restore_version: null,
}),
)
.finally(() => setCheckingTools(false));
}, []);
// Fetch schemas from the source connection when it changes
useEffect(() => {
if (!sourceConnectionId) {
setAvailableSchemas([]);
setSchema("");
return;
}
getSchemas(sourceConnectionId)
.then((schemas) => setAvailableSchemas(schemas))
.catch(() => setAvailableSchemas([]));
}, [sourceConnectionId]);
const handleStartSync = useCallback(async () => {
if (!sourceConnectionId || !targetConnectionId) {
notify("Please select both source and target connections", "error");
return;
}
if (sourceConnectionId === targetConnectionId) {
notify("Source and target must be different", "error");
return;
}
const jobId = `sync-${Date.now()}`;
startJob(jobId, "sync");
pendingJobRef.current = jobId;
try {
await dbSync({
sourceConnectionId,
targetConnectionId,
schema: schema || undefined,
tables: undefined,
});
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
useBackupStore.getState().failJob(jobId, msg);
}
}, [sourceConnectionId, targetConnectionId, schema, startJob, notify]);
const toolsMissing =
toolStatus &&
(!toolStatus.pg_dump_found || !toolStatus.pg_restore_found);
const canStart =
sourceConnectionId && targetConnectionId && confirmed && !isRunning;
const postgresqlConnections = connections.filter(
(c) => c.db_type === "postgresql",
);
return (
<div className="flex flex-col h-full">
{/* Toolbar header */}
<div className="flex items-center gap-2 border-b border-border px-3 py-1.5">
<ArrowLeftRight size={14} className="text-accent" />
<span className="text-xs font-medium text-text">DB Sync</span>
<span className="text-[11px] text-text-muted">
Transfer data between PostgreSQL databases via pipe
</span>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
<div className="max-w-lg mx-auto space-y-6 outline outline-border">
{/* Tool check */}
{checkingTools && (
<div className="glass p-4 text-center">
<p className="text-sm text-text-muted">
Checking for pg_dump / pg_restore...
</p>
</div>
)}
{toolsMissing && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2">
<p className="text-amber-300 text-sm font-semibold">
PostgreSQL tools not found
</p>
<p className="text-amber-200/80 text-xs leading-relaxed">
Both pg_dump and pg_restore are required for
database sync.
</p>
<ul className="list-disc list-inside text-xs text-amber-200/70 space-y-0.5">
{!toolStatus?.pg_dump_found && (
<li>pg_dump is missing.</li>
)}
{!toolStatus?.pg_restore_found && (
<li>pg_restore is missing.</li>
)}
</ul>
</div>
)}
{!checkingTools && !toolsMissing && (
<>
{/* Configuration card */}
<div className="p-5 space-y-5">
{/* Source & Target connection pickers */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium flex items-center gap-1">
<Database size={11} />
Source
</label>
<select
value={sourceConnectionId}
onChange={(e) =>
setSourceConnectionId(
e.target.value,
)
}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
>
<option value="">
Select source...
</option>
{postgresqlConnections.map((c) => (
<option
key={c.id}
value={c.id}
disabled={
c.id ===
targetConnectionId
}
>
{c.name}
</option>
))}
</select>
</div>
<div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium flex items-center gap-1">
<Database size={11} />
Target
</label>
<select
value={targetConnectionId}
onChange={(e) =>
setTargetConnectionId(
e.target.value,
)
}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
>
<option value="">
Select target...
</option>
{postgresqlConnections.map((c) => (
<option
key={c.id}
value={c.id}
disabled={
c.id ===
sourceConnectionId
}
>
{c.name}
</option>
))}
</select>
</div>
</div>
{/* Schema (optional) */}
<div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Schema{" "}
<span className="font-normal normal-case tracking-normal">
(optional)
</span>
</label>
<select
value={schema}
onChange={(e) =>
setSchema(e.target.value)
}
disabled={!sourceConnectionId}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
>
<option value="">
{sourceConnectionId
? "All schemas"
: "Select a source first"}
</option>
{availableSchemas.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
{/* Flow indicator */}
{sourceConnectionId && targetConnectionId && (
<div className="flex items-center gap-3 text-[11px] text-text-muted">
<span className="font-medium text-text">
{postgresqlConnections.find(
(c) =>
c.id === sourceConnectionId,
)?.name ?? sourceConnectionId}
</span>
<ArrowLeftRight
size={12}
className="text-accent shrink-0"
/>
<span className="font-medium text-text">
{postgresqlConnections.find(
(c) =>
c.id === targetConnectionId,
)?.name ?? targetConnectionId}
</span>
</div>
)}
</div>
{/* Destructive confirmation */}
<div className="bg-red-500/5 border border-red-500/20 px-4 py-3">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={confirmed}
onChange={(e) =>
setConfirmed(e.target.checked)
}
className="mt-0.5 rounded bg-surface border-border accent-red-500 w-4 h-4 cursor-pointer"
data-testid="sync-confirm-checkbox"
/>
<span className="text-sm text-red-300/90 leading-relaxed">
I understand this will overwrite data on
the target database. This action cannot
be undone.
</span>
</label>
</div>
{/* Progress */}
{activeJob && (
<div className="px-4">
<BackupProgress
progress={activeJob.status === "completed" ? 100 : 50}
jobType="sync"
status={activeJob.status}
errorMessage={activeJob.error_message ?? undefined}
/>
</div>
)}
{/* Actions */}
<div className="flex justify-end pb-2 pr-2">
<Button
onClick={handleStartSync}
disabled={!canStart}
>
<ArrowLeftRight
size={14}
className="mr-1.5"
/>
{isRunning ? "Syncing..." : "Start Sync"}
</Button>
</div>
</>
)}
</div>
</div>
</div>
);
}
+4 -7
View File
@@ -27,20 +27,17 @@ export function TabBar() {
key={tab.id}
role="tab"
aria-selected={isActive}
onClick={() => setActiveTab(tab.id)}
className={[
"group flex shrink-0 items-center gap-2 border-r border-border px-3 text-sm transition-colors",
"group flex shrink-0 items-center gap-2 border-r border-border px-3 text-sm transition-colors cursor-pointer",
isActive
? "bg-canvas text-text"
: "text-text-muted hover:text-text",
].join(" ")}
>
<button
type="button"
onClick={() => setActiveTab(tab.id)}
className="flex-1 text-left outline-none cursor-pointer"
>
<span className="flex-1 text-left select-none">
{tab.table}
</button>
</span>
<button
type="button"
onClick={(e) => {
+2 -2
View File
@@ -17,7 +17,7 @@ const AUTO_REFRESH_OPTIONS = [
{ label: "5m", value: 300_000 },
] as const;
const PAGE_SIZES = [50, 100, 200] as const;
const PAGE_SIZES = [50, 100, 200, 500] as const;
const EXPORT_FORMATS = [
{ label: "JSON", ext: "json" },
@@ -560,7 +560,7 @@ export function TableControls({
};
return (
<div className="flex items-center gap-2 border-b border-border px-3 py-1.5 bg-surface/50 text-xs text-text-muted">
<div className="flex items-center gap-2 border-b border-border px-3 py-1.5 text-xs text-text-muted">
{/* ── left side ──────────────────────────────── */}
<div className="flex items-center gap-1">
{/* Insert Row */}
+141 -99
View File
@@ -8,107 +8,149 @@ import type { ColumnInfo } from "../../lib/types";
import * as cmd from "../../lib/commands";
export function TableTree({ searchQuery }: { searchQuery?: string }) {
const tables = useDbViewerStore((s) => s.tables);
const currentSchema = useDbViewerStore((s) => s.currentSchema);
const openTab = useDbViewerStore((s) => s.openTab);
const connectionId = useUiStore((s) => s.activeConnectionId);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [columnCache, setColumnCache] = useState<Record<string, ColumnInfo[]>>({});
const tables = useDbViewerStore((s) => s.tables);
const currentSchema = useDbViewerStore((s) => s.currentSchema);
const openTab = useDbViewerStore((s) => s.openTab);
const connectionId = useUiStore((s) => s.activeConnectionId);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [columnCache, setColumnCache] = useState<
Record<string, ColumnInfo[]>
>({});
const q = (searchQuery ?? "").toLowerCase().trim();
const q = (searchQuery ?? "").toLowerCase().trim();
const filteredTables = (currentSchema
? tables.filter((t) => t.schema === currentSchema)
: tables).filter((t) => !q || t.name.toLowerCase().includes(q));
const filteredTables = (
currentSchema
? tables.filter((t) => t.schema === currentSchema)
: tables
).filter((t) => !q || t.name.toLowerCase().includes(q));
const toggle = async (key: string, schema: string, tableName: string) => {
const isExpanded = expanded.has(key);
setExpanded((prev) => {
const next = new Set(prev);
if (isExpanded) next.delete(key);
else next.add(key);
return next;
});
// Fetch columns if not cached
if (!isExpanded && !columnCache[key] && connectionId) {
try {
const result = await cmd.getTableData(connectionId, schema, tableName, 1, 0);
setColumnCache((prev) => ({ ...prev, [key]: result.columns }));
} catch { /* ignore, columns will remain unknowns */ }
}
};
const handleOpenTab = (schema: string, table: string, forceNew?: boolean) => {
openTab(schema, table, forceNew);
return "tab";
};
return (
<div className="py-2">
{filteredTables.length === 0 && (
<div className="px-3 py-2 text-sm text-text-muted">No tables</div>
)}
{filteredTables.map((table) => {
const key = `${table.schema}.${table.name}`;
const toggle = async (key: string, schema: string, tableName: string) => {
const isExpanded = expanded.has(key);
const cols = columnCache[key] ?? table.columns ?? [];
return (
<div key={key}>
<div
className="group flex items-center gap-1 px-3 py-1 hover:bg-surface-raised cursor-pointer"
onClick={() => openTab(table.schema, table.name)}
>
<button
aria-label={isExpanded ? "Collapse" : "Expand"}
onClick={(e) => {
e.stopPropagation();
toggle(key, table.schema, table.name);
}}
className="w-5 h-5 flex items-center justify-center text-text-muted hover:text-text cursor-pointer"
>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
<Table2 size={14} className="text-text-muted" />
<span className="flex-1 text-left text-sm text-text group-hover:text-accent truncate">
{table.name}
</span>
<div onClick={(e) => e.stopPropagation()}>
<TableOverflowMenu
schema={table.schema}
table={table.name}
onOpenTab={handleOpenTab}
/>
</div>
</div>
{isExpanded && (
<div className="pl-10 pr-3 py-1 space-y-1">
{cols.length === 0 && (
<div className="text-xs text-text-muted">No columns</div>
)}
{cols.map((col) => (
<div
key={col.name}
className="flex items-center gap-2 text-xs text-text-muted"
title={col.is_fk && col.fk_ref
? `${col.data_type}${col.fk_ref[0]}.${col.fk_ref[1]}`
: col.data_type}
>
{col.is_pk ? (
<Key size={12} className="text-accent shrink-0" />
) : col.is_fk ? (
<Key size={12} className="text-amber-400 shrink-0" />
) : (
<Type size={12} className="shrink-0" />
)}
<span className="truncate">{col.name}</span>
<span className="text-text-subtle truncate" title={col.data_type}>{abbreviateType(col.data_type)}</span>
</div>
))}
</div>
setExpanded((prev) => {
const next = new Set(prev);
if (isExpanded) next.delete(key);
else next.add(key);
return next;
});
// Fetch columns if not cached
if (!isExpanded && !columnCache[key] && connectionId) {
try {
const result = await cmd.getTableData(
connectionId,
schema,
tableName,
1,
0,
);
setColumnCache((prev) => ({ ...prev, [key]: result.columns }));
} catch {
/* ignore, columns will remain unknowns */
}
}
};
const handleOpenTab = (
schema: string,
table: string,
forceNew?: boolean,
) => {
openTab(schema, table, forceNew);
return "tab";
};
return (
<div>
{filteredTables.length === 0 && (
<div className="px-3 py-2 text-sm text-text-muted">
No tables
</div>
)}
</div>
);
})}
</div>
);
}
{filteredTables.map((table) => {
const key = `${table.schema}.${table.name}`;
const isExpanded = expanded.has(key);
const cols = columnCache[key] ?? table.columns ?? [];
return (
<div key={key}>
<div
className="group flex items-center gap-1 px-3 py-1 hover:bg-surface-raised cursor-pointer"
onClick={() => openTab(table.schema, table.name)}
>
<button
aria-label={isExpanded ? "Collapse" : "Expand"}
onClick={(e) => {
e.stopPropagation();
toggle(key, table.schema, table.name);
}}
className="w-5 h-5 flex items-center justify-center text-text-muted hover:text-text cursor-pointer"
>
{isExpanded ? (
<ChevronDown size={14} />
) : (
<ChevronRight size={14} />
)}
</button>
<Table2 size={14} className="text-text-muted" />
<span className="flex-1 text-left text-sm text-text group-hover:text-accent truncate">
{table.name}
</span>
<div onClick={(e) => e.stopPropagation()}>
<TableOverflowMenu
schema={table.schema}
table={table.name}
onOpenTab={handleOpenTab}
/>
</div>
</div>
{isExpanded && (
<div className="pl-10 pr-3 py-1 space-y-1">
{cols.length === 0 && (
<div className="text-xs text-text-muted">
No columns
</div>
)}
{cols.map((col) => (
<div
key={col.name}
className="flex items-center gap-2 text-xs text-text-muted"
title={
col.is_fk && col.fk_ref
? `${col.data_type}${col.fk_ref[0]}.${col.fk_ref[1]}`
: col.data_type
}
>
{col.is_pk ? (
<Key
size={12}
className="text-accent shrink-0"
/>
) : col.is_fk ? (
<Key
size={12}
className="text-amber-400 shrink-0"
/>
) : (
<Type
size={12}
className="shrink-0"
/>
)}
<span className="truncate">
{col.name}
</span>
<span
className="text-text-subtle truncate"
title={col.data_type}
>
{abbreviateType(col.data_type)}
</span>
</div>
))}
</div>
)}
</div>
);
})}
</div>
);
}
+8 -3
View File
@@ -1,9 +1,14 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { DndContext } from "@dnd-kit/core";
import { FolderTree } from "./FolderTree";
import type { Folder } from "../../lib/types";
function Wrapper({ children }: { children: React.ReactNode }) {
return <DndContext>{children}</DndContext>;
}
const folders: Folder[] = [
{ id: "f1", name: "Work", parent_id: null, tag_ids: [], created_at: "", updated_at: "" },
{ id: "f2", name: "ClientA", parent_id: "f1", tag_ids: [], created_at: "", updated_at: "" },
@@ -11,7 +16,7 @@ const folders: Folder[] = [
describe("FolderTree", () => {
it("renders all folders", () => {
render(<FolderTree folders={folders} activeFolderId={null} onSelect={() => {}} />);
render(<FolderTree folders={folders} activeFolderId={null} onSelect={() => {}} />, { wrapper: Wrapper });
expect(screen.getByText("Work")).toBeInTheDocument();
expect(screen.getByText("ClientA")).toBeInTheDocument();
});
@@ -19,7 +24,7 @@ describe("FolderTree", () => {
it("renders All Connections option that clears filter", async () => {
const user = userEvent.setup();
const fn = vi.fn();
render(<FolderTree folders={folders} activeFolderId="f1" onSelect={fn} />);
render(<FolderTree folders={folders} activeFolderId="f1" onSelect={fn} />, { wrapper: Wrapper });
await user.click(screen.getByText(/all connections/i));
expect(fn).toHaveBeenCalledWith(null);
});
@@ -27,7 +32,7 @@ describe("FolderTree", () => {
it("selecting a folder calls onSelect with id", async () => {
const user = userEvent.setup();
const fn = vi.fn();
render(<FolderTree folders={folders} activeFolderId={null} onSelect={fn} />);
render(<FolderTree folders={folders} activeFolderId={null} onSelect={fn} />, { wrapper: Wrapper });
await user.click(screen.getByText("Work"));
expect(fn).toHaveBeenCalledWith("f1");
});
+23 -5
View File
@@ -1,5 +1,6 @@
import type { Folder } from "../../lib/types";
import { ChevronRight, Folder as FolderIcon } from "lucide-react";
import { useDroppable } from "@dnd-kit/core";
interface FolderTreeProps {
folders: Folder[];
@@ -10,19 +11,31 @@ interface FolderTreeProps {
export function FolderTree({ folders, activeFolderId, onSelect }: FolderTreeProps) {
const roots = folders.filter((f) => f.parent_id === null);
const childrenOf = (id: string) => folders.filter((f) => f.parent_id === id);
const { setNodeRef: setRootRef, isOver: isRootOver } = useDroppable({
id: "root",
});
const renderFolder = (folder: Folder, depth: number) => {
const FolderItem = ({ folder, depth }: { folder: Folder; depth: number }) => {
const isActive = activeFolderId === folder.id;
const { setNodeRef: setDropRef, isOver } = useDroppable({
id: `folder-${folder.id}`,
data: { type: "folder", folder },
});
return (
<div key={folder.id}>
<button
ref={setDropRef}
onClick={() => onSelect(folder.id)}
className={`flex items-center gap-1 w-full text-left px-2 py-1 rounded text-sm ${isActive ? "bg-accent/20 text-white" : "text-white/70 hover:text-white"}`}
className={`flex items-center gap-1 w-full text-left px-2 py-1 rounded text-sm ${
isActive ? "bg-accent/20 text-white" : "text-white/70 hover:text-white"
} ${isOver ? "ring-1 ring-accent bg-accent/10" : ""}`}
style={{ paddingLeft: `${depth * 12 + 8}px` }}
>
<FolderIcon size={14} /> {folder.name}
</button>
{childrenOf(folder.id).map((c) => renderFolder(c, depth + 1))}
{childrenOf(folder.id).map((c) => (
<FolderItem key={c.id} folder={c} depth={depth + 1} />
))}
</div>
);
};
@@ -30,12 +43,17 @@ export function FolderTree({ folders, activeFolderId, onSelect }: FolderTreeProp
return (
<div className="space-y-0.5">
<button
ref={setRootRef}
onClick={() => onSelect(null)}
className={`flex items-center gap-1 w-full text-left px-2 py-1 rounded text-sm ${activeFolderId === null ? "bg-accent/20 text-white" : "text-white/70 hover:text-white"}`}
className={`flex items-center gap-1 w-full text-left px-2 py-1 rounded text-sm ${
activeFolderId === null ? "bg-accent/20 text-white" : "text-white/70 hover:text-white"
} ${isRootOver ? "ring-1 ring-accent bg-accent/10" : ""}`}
>
<ChevronRight size={14} /> All Connections
</button>
{roots.map((r) => renderFolder(r, 0))}
{roots.map((r) => (
<FolderItem key={r.id} folder={r} depth={0} />
))}
</div>
);
}
@@ -0,0 +1,245 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { VirtualDataGrid } from "./VirtualDataGrid";
import type { ColumnInfo } from "../../lib/types";
const mockColumns: ColumnInfo[] = [
{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null },
{ name: "name", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null },
];
const mockRows: unknown[][] = [
[1, "Alice"],
[2, "Bob"],
];
/**
* Override `useVirtualizer` so virtual items are always rendered
* regardless of container dimensions in jsdom.
*/
const { mockGetVirtualItems, mockGetTotalSize, mockMeasureElement } = vi.hoisted(() => ({
mockGetVirtualItems: vi.fn(),
mockGetTotalSize: vi.fn(),
mockMeasureElement: vi.fn(),
}));
vi.mock("@tanstack/react-virtual", () => ({
useVirtualizer: () => ({
getVirtualItems: mockGetVirtualItems,
getTotalSize: mockGetTotalSize,
measureElement: mockMeasureElement,
}),
}));
describe("VirtualDataGrid", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders all rows when row count is small", () => {
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(
mockRows.map((_, i) => ({
key: i,
index: i,
start: i * 36,
size: 36,
})),
);
render(
<VirtualDataGrid
connectionId="conn-1"
schema="public"
rows={mockRows}
columns={mockColumns}
hiddenColumns={new Set()}
selectedRows={new Set()}
onToggleRow={() => {}}
onToggleAll={() => {}}
/>,
);
expect(screen.getByText("Alice")).toBeInTheDocument();
expect(screen.getByText("Bob")).toBeInTheDocument();
});
it("renders column headers with type badges", () => {
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(
mockRows.map((_, i) => ({
key: i,
index: i,
start: i * 36,
size: 36,
})),
);
render(
<VirtualDataGrid
connectionId="conn-1"
schema="public"
rows={mockRows}
columns={mockColumns}
hiddenColumns={new Set()}
selectedRows={new Set()}
onToggleRow={() => {}}
onToggleAll={() => {}}
/>,
);
expect(screen.getByText("id")).toBeInTheDocument();
expect(screen.getByText("name")).toBeInTheDocument();
expect(screen.getByText("int")).toBeInTheDocument();
});
it("renders NULL values in italic", () => {
const rows: unknown[][] = [[null, "HasNull"]];
mockGetTotalSize.mockReturnValue(rows.length * 36);
mockGetVirtualItems.mockReturnValue(
rows.map((_, i) => ({
key: i,
index: i,
start: i * 36,
size: 36,
})),
);
render(
<VirtualDataGrid
connectionId="conn-1"
schema="public"
rows={rows}
columns={mockColumns}
hiddenColumns={new Set()}
selectedRows={new Set()}
onToggleRow={() => {}}
onToggleAll={() => {}}
/>,
);
expect(screen.getByText("NULL")).toBeInTheDocument();
expect(screen.getByText("NULL").className).toContain("italic");
});
it("renders empty state when no rows", () => {
mockGetTotalSize.mockReturnValue(0);
mockGetVirtualItems.mockReturnValue([]);
render(
<VirtualDataGrid
connectionId="conn-1"
schema="public"
rows={[]}
columns={mockColumns}
hiddenColumns={new Set()}
selectedRows={new Set()}
onToggleRow={() => {}}
onToggleAll={() => {}}
/>,
);
expect(screen.getByText(/no rows/i)).toBeInTheDocument();
});
it("calls onToggleRow when checkbox clicked", () => {
let toggled = -1;
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
render(<VirtualDataGrid connectionId="conn-1" schema="public" rows={mockRows} columns={mockColumns}
hiddenColumns={new Set()} selectedRows={new Set()}
onToggleRow={(i) => { toggled = i; }} onToggleAll={() => {}} />);
const checkboxes = screen.getAllByRole("checkbox");
fireEvent.click(checkboxes[1]); // first row checkbox
expect(toggled).toBe(0);
});
it("renders FK cells with clickable underline styling", () => {
const fkCols: ColumnInfo[] = [
{ name: "user_id", data_type: "integer", is_nullable: false, is_pk: false, is_fk: true, fk_ref: ["users", "id"], default_value: null },
];
mockGetTotalSize.mockReturnValue(36);
mockGetVirtualItems.mockReturnValue([{ key: 0, index: 0, start: 0, size: 36 }]);
render(<VirtualDataGrid connectionId="conn-1" schema="public" rows={[[42]]} columns={fkCols}
hiddenColumns={new Set()} selectedRows={new Set()}
onToggleRow={() => {}} onToggleAll={() => {}} />);
const fkCell = screen.getByText("42");
expect(fkCell.className).toContain("cursor-pointer");
expect(fkCell.className).toContain("underline");
});
it("renders JSON cells with preview label", () => {
const jsonCols: ColumnInfo[] = [
{ name: "metadata", data_type: "jsonb", is_nullable: false, is_pk: false, is_fk: false, fk_ref: null, default_value: null },
];
mockGetTotalSize.mockReturnValue(36);
mockGetVirtualItems.mockReturnValue([{ key: 0, index: 0, start: 0, size: 36 }]);
render(<VirtualDataGrid connectionId="conn-1" schema="public" rows={[[JSON.stringify({ key: "val", count: 3 })]]} columns={jsonCols}
hiddenColumns={new Set()} selectedRows={new Set()}
onToggleRow={() => {}} onToggleAll={() => {}} />);
expect(screen.getByText(/2 keys/)).toBeInTheDocument();
});
it("has resize handles on column headers", () => {
mockGetTotalSize.mockReturnValue(0);
mockGetVirtualItems.mockReturnValue([]);
render(<VirtualDataGrid connectionId="conn-1" schema="public" rows={[]} columns={mockColumns}
hiddenColumns={new Set()} selectedRows={new Set()}
onToggleRow={() => {}} onToggleAll={() => {}} />);
const handles = document.querySelectorAll('[class*="cursor-col-resize"]');
expect(handles.length).toBe(2); // one per visible column
});
it("renders 10000 rows without crashing (virtualization)", () => {
const bigRows: unknown[][] = Array.from({ length: 10000 }, (_, i) => [i, `Name${i}`]);
mockGetTotalSize.mockReturnValue(10000 * 36);
mockGetVirtualItems.mockReturnValue(
Array.from({ length: 20 }, (_, i) => ({ key: i, index: i, start: i * 36, size: 36 }))
);
render(
<VirtualDataGrid connectionId="conn-1" schema="public" rows={bigRows} columns={mockColumns}
hiddenColumns={new Set()} selectedRows={new Set()}
onToggleRow={() => {}} onToggleAll={() => {}} />,
);
const checkboxes = screen.getAllByRole("checkbox");
expect(checkboxes.length).toBeLessThan(50); // virtualized: only visible rows + select all
});
it("shows select-all as checked when all rows selected", () => {
const allSelected = new Set([0, 1]);
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(
mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 }))
);
render(
<VirtualDataGrid connectionId="conn-1" schema="public" rows={mockRows} columns={mockColumns}
hiddenColumns={new Set()} selectedRows={allSelected}
onToggleRow={() => {}} onToggleAll={() => {}} />,
);
const selectAll = screen.getAllByRole("checkbox")[0] as HTMLInputElement;
expect(selectAll.checked).toBe(true);
});
it("hides columns in hiddenColumns set", () => {
const hidden = new Set(["name"]);
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(
mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 }))
);
render(
<VirtualDataGrid connectionId="conn-1" schema="public" rows={mockRows} columns={mockColumns}
hiddenColumns={hidden} selectedRows={new Set()}
onToggleRow={() => {}} onToggleAll={() => {}} />,
);
expect(screen.queryByText("name")).not.toBeInTheDocument();
expect(screen.getByText("id")).toBeInTheDocument();
});
});
+319
View File
@@ -0,0 +1,319 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { Key, Braces } from "lucide-react";
import type { ColumnInfo } from "../../lib/types";
import { abbreviateType } from "../../lib/utils";
import { FkPreviewPopover } from "../db-viewer/FkPreviewPopover";
import { JsonCellPopover, jsonPreview } from "../db-viewer/JsonCellPopover";
interface VirtualDataGridProps {
connectionId: string;
schema: string;
rows: unknown[][];
columns: ColumnInfo[];
hiddenColumns: Set<string>;
selectedRows: Set<number>;
onToggleRow: (rowIndex: number) => void;
onToggleAll: () => void;
}
const ROW_HEIGHT = 36;
const DEFAULT_COL_WIDTH = 200;
const MIN_COL_WIDTH = 60;
const MAX_COL_WIDTH = 800;
export function VirtualDataGrid({
connectionId,
schema,
rows,
columns,
hiddenColumns,
selectedRows,
onToggleRow,
onToggleAll,
}: VirtualDataGridProps) {
const parentRef = useRef<HTMLDivElement>(null);
const visibleColumns = columns.filter((c) => !hiddenColumns.has(c.name));
const allSelected = rows.length > 0 && selectedRows.size === rows.length;
const selectAllRef = useRef<HTMLInputElement>(null);
// Indeterminate state for partial selection
useEffect(() => {
if (selectAllRef.current) {
selectAllRef.current.indeterminate = selectedRows.size > 0 && selectedRows.size < rows.length;
}
}, [selectedRows.size, rows.length]);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 5,
});
// ── column widths ─────────────────────────────────────
const [colWidths, setColWidths] = useState<Record<string, number>>({});
const getWidth = useCallback(
(colName: string) => colWidths[colName] ?? DEFAULT_COL_WIDTH,
[colWidths],
);
// Total width for horizontal scroll support
const totalWidth = 40 + visibleColumns.reduce((sum, c) => sum + getWidth(c.name), 0);
const resizeRef = useRef<{ col: string; startX: number; startWidth: number } | null>(null);
const startResize = useCallback(
(colName: string, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
resizeRef.current = { col: colName, startX: e.clientX, startWidth: getWidth(colName) };
const onMove = (ev: MouseEvent) => {
const current = resizeRef.current;
if (!current) return;
const delta = ev.clientX - current.startX;
const next = Math.max(MIN_COL_WIDTH, Math.min(MAX_COL_WIDTH, current.startWidth + delta));
setColWidths((prev) => ({ ...prev, [current.col]: next }));
};
const onUp = () => {
resizeRef.current = null;
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
},
[getWidth],
);
const resetWidth = useCallback((colName: string) => {
setColWidths((prev) => {
const next = { ...prev };
delete next[colName];
return next;
});
}, []);
// ── FK preview popover state ──────────────────────────
const [fkPreview, setFkPreview] = useState<{
connectionId: string;
schema: string;
table: string;
column: string;
value: string;
anchorRect: DOMRect | null;
} | null>(null);
const handleFkClick = useCallback(
(col: ColumnInfo, cellValue: unknown, e: React.MouseEvent) => {
if (!col.is_fk || !col.fk_ref || cellValue === null || cellValue === undefined) return;
const [refTable] = col.fk_ref;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setFkPreview({
connectionId,
schema,
table: refTable,
column: col.fk_ref[1],
value: String(cellValue),
anchorRect: rect,
});
},
[connectionId, schema],
);
// ── JSON popover state ────────────────────────────────
const [jsonPopover, setJsonPopover] = useState<{
value: unknown;
anchorRect: DOMRect | null;
} | null>(null);
// ── cell renderer (shared between header sizing and body) ──
const renderCell = useCallback(
(col: ColumnInfo, row: unknown[], _rowIndex: number) => {
const ci = columns.findIndex((c) => c.name === col.name);
const cell = ci >= 0 ? row[ci] : undefined;
const isNull = cell === null || cell === undefined;
const isFk = col.is_fk && col.fk_ref && !isNull;
const isJson = !isNull && (col.data_type === "jsonb" || col.data_type === "json");
const jp = isJson ? jsonPreview(cell) : { label: "", isJson: false };
const handleJsonClick = (e: React.MouseEvent) => {
if (isJson) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setJsonPopover({ value: cell, anchorRect: rect });
}
};
return (
<div
key={col.name}
className={`px-3 py-2 font-heading text-xs truncate select-text border-r border-border self-stretch ${
isFk ? "cursor-pointer underline decoration-dotted underline-offset-2 hover:text-accent" : ""
} ${isJson ? "cursor-pointer text-accent/80 hover:text-accent" : ""}`}
role={isFk || isJson ? "button" : undefined}
tabIndex={isFk || isJson ? 0 : undefined}
onKeyDown={
isFk || isJson
? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (isFk) handleFkClick(col, cell, e as any);
else if (isJson) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setJsonPopover({ value: cell, anchorRect: rect });
}
}
}
: undefined
}
style={{ width: getWidth(col.name), flexShrink: 0 }}
title={
isNull
? "NULL"
: isFk
? `FK → ${col.fk_ref![0]}.${col.fk_ref![1]}: ${String(cell)}`
: isJson
? "Click to view JSON"
: String(cell)
}
onClick={
isFk
? (e) => handleFkClick(col, cell, e)
: isJson
? handleJsonClick
: undefined
}
>
{isNull ? (
<span className="italic text-text-muted">NULL</span>
) : isJson ? (
<span className="inline-flex items-center gap-0.5">
<Braces size={10} className="shrink-0" />
{jp.label}
</span>
) : (
String(cell)
)}
</div>
);
},
[columns, getWidth, handleFkClick],
);
return (
<div ref={parentRef} className="overflow-auto h-full" style={{ overscrollBehavior: "none" }}>
{/* ── sticky header ── */}
<div className="sticky top-0 z-10">
<div className="flex items-center border-b border-border bg-canvas" style={{ minWidth: 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}
type="checkbox"
checked={allSelected}
onChange={onToggleAll}
className="w-3.5 h-3.5 rounded border-border cursor-pointer accent-accent"
/>
</div>
{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"
style={{ width: getWidth(col.name), flexShrink: 0 }}
>
<div className="truncate flex items-center gap-1">
{col.is_pk && <Key size={10} className="text-accent shrink-0" />}
{col.is_fk && <Key size={10} className="text-amber-400 shrink-0" />}
<span className="text-text text-xs">{col.name}</span>
<span className="text-[10px] text-text-muted/50 shrink-0" title={col.data_type}>
{abbreviateType(col.data_type)}
</span>
</div>
<div
className="absolute right-0 top-0 h-full w-[6px] cursor-col-resize select-none bg-transparent hover:bg-accent/30 active:bg-accent/50"
onMouseDown={(e) => startResize(col.name, e)}
onDoubleClick={() => resetWidth(col.name)}
/>
</div>
))}
</div>
</div>
{/* ── virtual body ── */}
{rows.length === 0 ? (
<div className="py-12 text-center text-sm text-text-muted">
No rows in result set
</div>
) : (
<div
style={{
height: virtualizer.getTotalSize(),
position: "relative",
width: "100%",
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const row = rows[virtualRow.index];
const isSelected = selectedRows.has(virtualRow.index);
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
className={`flex items-center border-b border-border ${
isSelected ? "bg-accent/5" : ""
} hover:bg-surface/50`}
style={{
position: "absolute",
top: 0,
left: 0,
minWidth: totalWidth,
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
<div style={{ width: 40, minWidth: 40 }} className="flex items-center justify-center border-r border-border self-stretch">
<input
type="checkbox"
checked={isSelected}
onChange={() => onToggleRow(virtualRow.index)}
className="w-3.5 h-3.5 rounded border-border cursor-pointer accent-accent"
/>
</div>
{visibleColumns.map((col) => renderCell(col, row, virtualRow.index))}
</div>
);
})}
</div>
)}
{/* FK preview popover */}
{fkPreview && (
<FkPreviewPopover
connectionId={fkPreview.connectionId}
schema={fkPreview.schema}
table={fkPreview.table}
column={fkPreview.column}
value={fkPreview.value}
anchorRect={fkPreview.anchorRect}
onClose={() => setFkPreview(null)}
/>
)}
{/* JSON cell popover */}
{jsonPopover && (
<JsonCellPopover
value={jsonPopover.value}
anchorRect={jsonPopover.anchorRect}
onClose={() => setJsonPopover(null)}
/>
)}
</div>
);
}
+62 -15
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { DndContext, DragOverlay, closestCenter, type DragEndEvent } from "@dnd-kit/core";
import { useConnectionStore } from "../../stores/connectionStore";
import { useUiStore } from "../../stores/uiStore";
import { useFilteredConnections } from "../../hooks/useConnections";
@@ -7,6 +8,7 @@ import { SearchBar } from "../search/SearchBar";
import type { SearchBarHandle } from "../search/SearchBar";
import { ActionRow } from "./ActionRow";
import { ConnectionGrid } from "../connections/ConnectionGrid";
import { ConnectionCard } from "../connections/ConnectionCard";
import { CreateFolderDialog } from "../folders/CreateFolderDialog";
import { EditFolderDialog } from "../folders/EditFolderDialog";
import { ConfirmDialog } from "../ui/ConfirmDialog";
@@ -36,6 +38,7 @@ export function HomeScreen() {
type: "folder" | "selected";
folder?: Folder;
} | null>(null);
const [activeDragId, setActiveDragId] = useState<string | null>(null);
const searchRef = useRef<SearchBarHandle>(null);
const setSearchQuery = useUiStore((s) => s.setSearchQuery);
const setPrefilledConnectionString = useUiStore(
@@ -55,6 +58,29 @@ export function HomeScreen() {
setActiveView("new-connection");
};
const handleDragEnd = useCallback(async (event: DragEndEvent) => {
const { active, over } = event;
if (!over) return;
const connectionId = active.id as string;
let folderId: string | null = null;
if (over.id === "root") {
folderId = null;
} else if (typeof over.id === "string" && over.id.startsWith("folder-")) {
const folderData = (over.data.current as any)?.folder;
folderId = folderData?.id ?? null;
} else {
return; // dropped on something unexpected
}
try {
await useConnectionStore.getState().moveConnection(connectionId, folderId);
} catch {
// Error handling in store; no additional action needed here
}
}, []);
// Cmd+K to focus search (configurable in Settings → Shortcuts)
useShortcut("command_palette", () => {
searchRef.current?.focus();
@@ -140,20 +166,41 @@ export function HomeScreen() {
visibleItemIds={visibleItemIds}
/>
</div>
<ConnectionGrid
connections={connections}
tags={tags}
folders={folders}
activeFolderId={activeFolderId}
onFolderSelect={setActiveFolderId}
hasSearch={searchQuery.length > 0}
onTagToggle={toggleTag}
onOpenDbViewer={handleOpenDbViewer}
onEditFolder={(f) => setEditFolder(f)}
onDeleteFolder={(f) =>
setConfirmDelete({ type: "folder", folder: f })
}
/>
<DndContext
onDragStart={(event) => setActiveDragId(event.active.id as string)}
onDragEnd={async (event) => {
setActiveDragId(null);
await handleDragEnd(event);
}}
collisionDetection={closestCenter}
>
<ConnectionGrid
connections={connections}
tags={tags}
folders={folders}
activeFolderId={activeFolderId}
onFolderSelect={setActiveFolderId}
hasSearch={searchQuery.length > 0}
onTagToggle={toggleTag}
onOpenDbViewer={handleOpenDbViewer}
onEditFolder={(f) => setEditFolder(f)}
onDeleteFolder={(f) =>
setConfirmDelete({ type: "folder", folder: f })
}
/>
<DragOverlay dropAnimation={null}>
{activeDragId && connections.find((c) => c.id === activeDragId) ? (
<div className="opacity-80">
<ConnectionCard
connection={connections.find((c) => c.id === activeDragId)!}
tags={tags}
onTagToggle={() => {}}
onOpenDbViewer={() => {}}
/>
</div>
) : null}
</DragOverlay>
</DndContext>
<CreateFolderDialog
open={folderDialogOpen}
parentOptions={folders}
+45 -2
View File
@@ -1,5 +1,6 @@
import { invoke } from "@tauri-apps/api/core";
import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, ChangeItem } from "./types";
import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, ChangeItem, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo } from "./types";
import type { FilterRule, SortRule } from "../stores/dbViewerStore";
// NOTE on argument key naming:
// Tauri v2's #[tauri::command] macro converts Rust snake_case parameter names
@@ -76,8 +77,10 @@ export async function getTableData(
table: string,
page?: number,
pageSize?: number,
filters?: FilterRule[],
sorts?: SortRule[],
): Promise<QueryResult> {
return invoke<QueryResult>("get_table_data", { connectionId, schema, table, page, pageSize });
return invoke<QueryResult>("get_table_data", { connectionId, schema, table, page, pageSize, filters, sorts });
}
export async function executeChange(connectionId: string, change: ChangeItem): Promise<void> {
@@ -96,4 +99,44 @@ export async function getFkPreview(
export async function refreshConnection(connectionId: string): Promise<void> {
return invoke<void>("refresh_connection", { connectionId });
}
// ─── Backup / Restore / Sync ──────────────────────────────────
export async function detectPgTools(): Promise<PgToolStatus> {
return invoke<PgToolStatus>("detect_pg_tools");
}
export async function pgDump(connectionId: string, options: BackupOptions): Promise<string> {
return invoke<string>("pg_dump", { connectionId, options });
}
export async function pgRestore(connectionId: string, options: RestoreOptions): Promise<string> {
return invoke<string>("pg_restore", { connectionId, options });
}
export async function dbSync(options: SyncOptions): Promise<string> {
return invoke<string>("db_sync", { options });
}
// ─── Object Explorer (Functions, Triggers, Sequences, Enums, Extensions) ────
export async function getFunctions(connectionId: string, schema?: string): Promise<FunctionInfo[]> {
return invoke<FunctionInfo[]>("get_functions", { connectionId, schema });
}
export async function getTriggers(connectionId: string, schema?: string): Promise<TriggerInfo[]> {
return invoke<TriggerInfo[]>("get_triggers", { connectionId, schema });
}
export async function getSequences(connectionId: string, schema?: string): Promise<SequenceInfo[]> {
return invoke<SequenceInfo[]>("get_sequences", { connectionId, schema });
}
export async function getEnums(connectionId: string, schema?: string): Promise<EnumInfo[]> {
return invoke<EnumInfo[]>("get_enums", { connectionId, schema });
}
export async function getExtensions(connectionId: string): Promise<ExtensionInfo[]> {
return invoke<ExtensionInfo[]>("get_extensions", { connectionId });
}
+93
View File
@@ -180,9 +180,102 @@ export interface DbViewerTab {
updated_at: string;
}
export interface FunctionInfo {
name: string;
schema: string;
return_type: string;
argument_types: string[];
argument_names: string[];
argument_modes: string[];
language: string;
source: string | null;
kind: string;
}
export interface TriggerInfo {
name: string;
schema: string;
table_schema: string;
table_name: string;
event_manipulation: string;
action_timing: string;
action_orientation: string;
action_statement: string;
enabled: string;
}
export interface SequenceInfo {
name: string;
schema: string;
start_value: string;
min_value: string;
max_value: string;
increment: string;
current_value: string;
cycle: boolean;
}
export interface EnumInfo {
name: string;
schema: string;
labels: string[];
}
export interface ExtensionInfo {
name: string;
schema: string;
version: string;
comment: string | null;
}
export interface ConnectionTestResult {
ok: boolean;
error?: string | null;
server_version?: string | null;
latency_ms?: number | null;
}
// ─── Backup Types ────────────────────────────────────────────────
export interface BackupOptions {
format: "plain" | "custom" | "tar" | "directory";
filePath: string;
schema?: string;
tables?: string[];
noOwner: boolean;
}
export interface RestoreOptions {
format: string;
filePath: string;
clean: boolean;
schema?: string;
}
export interface SyncOptions {
sourceConnectionId: string;
targetConnectionId: string;
schema?: string;
tables?: string[];
}
export interface PgToolStatus {
pg_dump_found: boolean;
pg_restore_found: boolean;
pg_dump_version: string | null;
pg_restore_version: string | null;
}
export interface BackupJob {
id: string;
connection_id: string;
type: "dump" | "restore" | "sync";
format: string | null;
file_path: string | null;
source_connection_id: string | null;
status: "running" | "completed" | "failed" | "cancelled";
error_message: string | null;
size_bytes: number | null;
started_at: string;
completed_at: string | null;
}
+82
View File
@@ -0,0 +1,82 @@
import { create } from "zustand";
import { listen } from "@tauri-apps/api/event";
import type { BackupJob } from "../lib/types";
interface BackupJobEvent {
job_id: string;
status: "running" | "completed" | "failed";
error?: string | null;
}
interface BackupStore {
jobs: BackupJob[];
activeJobId: string | null;
progress: number;
startJob: (jobId: string, type: string) => void;
completeJob: (jobId: string) => void;
failJob: (jobId: string, error: string) => void;
initListener: () => Promise<void>;
}
export const useBackupStore = create<BackupStore>((set, get) => ({
jobs: [],
activeJobId: null,
progress: 0,
startJob: (jobId: string, type: string) =>
set((s) => ({
activeJobId: jobId,
progress: 0,
jobs: [
...s.jobs,
{
id: jobId,
connection_id: "",
type: type as BackupJob["type"],
format: null,
file_path: null,
source_connection_id: null,
status: "running",
error_message: null,
size_bytes: null,
started_at: new Date().toISOString(),
completed_at: null,
} satisfies BackupJob,
],
})),
completeJob: (jobId: string) =>
set((s) => ({
progress: 100,
jobs: s.jobs.map((j) =>
j.id === jobId
? { ...j, status: "completed" as const, completed_at: new Date().toISOString() }
: j,
),
})),
failJob: (jobId: string, error: string) =>
set((s) => ({
jobs: s.jobs.map((j) =>
j.id === jobId
? {
...j,
status: "failed" as const,
error_message: error,
completed_at: new Date().toISOString(),
}
: j,
),
})),
initListener: async () => {
await listen<BackupJobEvent>("backup-progress", (event) => {
const { job_id, status, error } = event.payload;
if (status === "completed") {
get().completeJob(job_id);
} else if (status === "failed") {
get().failJob(job_id, error || "Unknown error");
}
});
},
}));
+89
View File
@@ -58,4 +58,93 @@ describe("connectionStore", () => {
await useConnectionStore.getState().createFolder({ name: "Work", parent_id: null });
expect(useConnectionStore.getState().folders).toContainEqual(folder);
});
});
describe("moveConnection", () => {
const baseConn: Connection = {
id: "c1",
name: "My DB",
db_type: "postgresql",
host: "localhost",
port: null,
username: null,
database: "mydb",
folder_id: null,
keychain_ref: 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,
tag_ids: [],
created_at: "2024-01-01",
updated_at: "2024-01-01",
};
beforeEach(() => {
useConnectionStore.setState({
connections: [
baseConn,
{ ...baseConn, id: "c2", name: "Other", folder_id: "folder-1" },
],
});
});
it("optimistically moves connection to a folder", async () => {
vi.spyOn(commands, "updateConnection").mockResolvedValueOnce({
...baseConn,
folder_id: "folder-2",
} as Connection);
await useConnectionStore.getState().moveConnection("c1", "folder-2");
const conn = useConnectionStore
.getState()
.connections.find((c) => c.id === "c1");
expect(conn?.folder_id).toBe("folder-2");
});
it("moves connection to root when folderId is null", async () => {
vi.spyOn(commands, "updateConnection").mockResolvedValueOnce({
...baseConn,
id: "c2",
folder_id: null,
} as Connection);
await useConnectionStore.getState().moveConnection("c2", null);
const conn = useConnectionStore
.getState()
.connections.find((c) => c.id === "c2");
expect(conn?.folder_id).toBeNull();
});
it("rolls back on API failure", async () => {
vi.spyOn(commands, "updateConnection").mockRejectedValueOnce(
new Error("Network error"),
);
const original = useConnectionStore
.getState()
.connections.find((c) => c.id === "c1")!;
await expect(
useConnectionStore.getState().moveConnection("c1", "folder-3"),
).rejects.toThrow("Network error");
const conn = useConnectionStore
.getState()
.connections.find((c) => c.id === "c1");
expect(conn?.folder_id).toBe(original.folder_id);
});
it("no-ops when moving to same folder", async () => {
const spy = vi.spyOn(commands, "updateConnection");
await useConnectionStore.getState().moveConnection("c1", null); // c1 is already null
expect(spy).not.toHaveBeenCalled();
});
});
+44
View File
@@ -18,6 +18,7 @@ interface ConnectionState {
updateTag: (id: string, input: TagInput) => Promise<void>;
deleteTag: (id: string) => Promise<void>;
addTagToItems: (tagId: string, folderIds: string[], connectionIds: string[]) => Promise<void>;
moveConnection: (connectionId: string, newFolderId: string | null) => Promise<void>;
cachePassword: (connectionId: string, password: string) => Promise<void>;
getConnectionPassword: (connectionId: string) => Promise<string | null>;
}
@@ -116,4 +117,47 @@ export const useConnectionStore = create<ConnectionState>((set, get) => ({
),
}));
},
moveConnection: async (connectionId, newFolderId) => {
const state = get();
const conn = state.connections.find((c) => c.id === connectionId);
if (!conn) return;
if (conn.folder_id === newFolderId) return;
const previousConnections = [...state.connections];
// Optimistic update
set((s) => ({
connections: s.connections.map((c) =>
c.id === connectionId ? { ...c, folder_id: newFolderId } : c,
),
}));
try {
// Build a minimal ConnectionInput with only folder_id changed
const input: any = {
name: conn.name,
db_type: conn.db_type,
host: conn.host,
port: conn.port,
username: conn.username,
database: conn.database,
folder_id: newFolderId,
environment: conn.environment,
ssh_host: conn.ssh_host,
ssh_port: conn.ssh_port,
ssh_user: conn.ssh_user,
ssh_auth_method: conn.ssh_auth_method,
ssh_private_key_path: conn.ssh_private_key_path,
ssl_mode: conn.ssl_mode,
ssl_ca_path: conn.ssl_ca_path,
ssl_cert_path: conn.ssl_cert_path,
ssl_key_path: conn.ssl_key_path,
tag_ids: conn.tag_ids ?? [],
};
await cmd.updateConnection(connectionId, input);
} catch (e) {
set({ connections: previousConnections });
throw e;
}
},
}));
+91 -1
View File
@@ -1,10 +1,25 @@
import { create } from "zustand";
import type { QueryResult, TableInfo, ChangeItemType } from "../lib/types";
import type { QueryResult, TableInfo, ChangeItemType, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo } from "../lib/types";
// ─── Local types ────────────────────────────────────────────────
export type QueueStatus = "pending" | "cancelled" | "committed" | "failed";
export type FilterOperator = "eq" | "neq" | "contains" | "starts" | "ends" | "gt" | "lt" | "null" | "notnull";
export interface FilterRule {
id: string;
column: string;
operator: FilterOperator;
value: string;
}
export interface SortRule {
id: string;
column: string;
order: "asc" | "desc";
}
export interface QueueItem {
id: string;
type: ChangeItemType;
@@ -30,6 +45,10 @@ export interface ViewerTab {
error: string | null;
data: QueryResult | null;
columnFilter?: { column: string; value: string };
filterRules: FilterRule[];
sortRules: SortRule[];
hiddenColumns: string[];
smartSortApplied: boolean;
}
// ─── Auto-increment counters ───────────────────────────────────
@@ -46,6 +65,10 @@ const initialTab = (schema: string, table: string, defaultPageSize?: number): Vi
loading: true,
error: null,
data: null,
filterRules: [],
sortRules: [],
hiddenColumns: [],
smartSortApplied: false,
});
// ─── State interface ────────────────────────────────────────────
@@ -60,6 +83,11 @@ interface DbViewerState {
tables: TableInfo[];
currentDatabase: string | null;
currentSchema: string | null;
functions: FunctionInfo[] | null;
triggers: TriggerInfo[] | null;
sequences: SequenceInfo[] | null;
enums: EnumInfo[] | null;
extensions: ExtensionInfo[] | null;
// Actions
openTab: (schema: string, table: string, forceNew?: boolean) => void;
@@ -73,6 +101,11 @@ interface DbViewerState {
setTabError: (tabId: string, error: string) => void;
setColumnFilter: (tabId: string, column: string, value: string) => void;
clearColumnFilter: (tabId: string) => void;
setFilterRules: (tabId: string, rules: FilterRule[]) => void;
setSortRules: (tabId: string, rules: SortRule[]) => void;
setHiddenColumns: (tabId: string, columns: string[]) => void;
toggleHiddenColumn: (tabId: string, column: string) => void;
setSmartSortApplied: (tabId: string) => void;
addChange: (input: {
type: ChangeItemType;
sql?: string;
@@ -88,6 +121,11 @@ interface DbViewerState {
markChangeFailed: (changeId: string, error: string) => void;
setCurrentDatabase: (db: string | null) => void;
setCurrentSchema: (schema: string | null) => void;
setFunctions: (functions: FunctionInfo[]) => void;
setTriggers: (triggers: TriggerInfo[]) => void;
setSequences: (sequences: SequenceInfo[]) => void;
setEnums: (enums: EnumInfo[]) => void;
setExtensions: (extensions: ExtensionInfo[]) => void;
populate: (
databases: string[],
schemas: string[],
@@ -108,6 +146,11 @@ const initialState = {
tables: [] as TableInfo[],
currentDatabase: null as string | null,
currentSchema: null as string | null,
functions: null as FunctionInfo[] | null,
triggers: null as TriggerInfo[] | null,
sequences: null as SequenceInfo[] | null,
enums: null as EnumInfo[] | null,
extensions: null as ExtensionInfo[] | null,
};
// ─── Store ──────────────────────────────────────────────────────
@@ -202,6 +245,48 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
),
})),
setFilterRules: (tabId, rules) =>
set((state) => ({
tabs: state.tabs.map((t) =>
t.id === tabId ? { ...t, filterRules: rules, page: 1, loading: true, error: null } : t,
),
})),
setSortRules: (tabId, rules) =>
set((state) => ({
tabs: state.tabs.map((t) =>
t.id === tabId ? { ...t, sortRules: rules, page: 1, loading: true, error: null } : t,
),
})),
setHiddenColumns: (tabId, columns) =>
set((state) => ({
tabs: state.tabs.map((t) =>
t.id === tabId ? { ...t, hiddenColumns: columns } : t,
),
})),
toggleHiddenColumn: (tabId, column) =>
set((state) => ({
tabs: state.tabs.map((t) => {
if (t.id !== tabId) return t;
const exists = t.hiddenColumns.includes(column);
return {
...t,
hiddenColumns: exists
? t.hiddenColumns.filter((c) => c !== column)
: [...t.hiddenColumns, column],
};
}),
})),
setSmartSortApplied: (tabId) =>
set((state) => ({
tabs: state.tabs.map((t) =>
t.id === tabId ? { ...t, smartSortApplied: true } : t,
),
})),
addChange: (input) => {
const item: QueueItem = {
id: `ch-${++changeCounter}`,
@@ -244,6 +329,11 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
setCurrentDatabase: (db) => set({ currentDatabase: db }),
setCurrentSchema: (schema) => set({ currentSchema: schema }),
setFunctions: (functions) => set({ functions }),
setTriggers: (triggers) => set({ triggers }),
setSequences: (sequences) => set({ sequences }),
setEnums: (enums) => set({ enums }),
setExtensions: (extensions) => set({ extensions }),
populate: (databases, schemas, tables) =>
set({ databases, schemas, tables }),