diff --git a/AGENTS.md b/AGENTS.md index 8bb1ee1..3ab55bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ``; 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) | ❌ | | diff --git a/README.md b/README.md index fc3c89c..357492c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/bun.lock b/bun.lock index 1a80624..326b5d1 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index f58dc04..a5d90f4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f48e261..b8ba392 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1763,7 +1763,7 @@ dependencies = [ [[package]] name = "gridline" -version = "0.1.0" +version = "0.2.0" dependencies = [ "chrono", "deadpool-postgres", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e35e1ed..8917729 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/icons.backup/128x128.png b/src-tauri/icons.backup/128x128.png new file mode 100644 index 0000000..5fd9c3b Binary files /dev/null and b/src-tauri/icons.backup/128x128.png differ diff --git a/src-tauri/icons.backup/128x128@2x.png b/src-tauri/icons.backup/128x128@2x.png new file mode 100644 index 0000000..535bc6d Binary files /dev/null and b/src-tauri/icons.backup/128x128@2x.png differ diff --git a/src-tauri/icons.backup/32x32.png b/src-tauri/icons.backup/32x32.png new file mode 100644 index 0000000..07ecddb Binary files /dev/null and b/src-tauri/icons.backup/32x32.png differ diff --git a/src-tauri/icons.backup/64x64.png b/src-tauri/icons.backup/64x64.png new file mode 100644 index 0000000..7a525e3 Binary files /dev/null and b/src-tauri/icons.backup/64x64.png differ diff --git a/src-tauri/icons.backup/Square107x107Logo.png b/src-tauri/icons.backup/Square107x107Logo.png new file mode 100644 index 0000000..dbc52a6 Binary files /dev/null and b/src-tauri/icons.backup/Square107x107Logo.png differ diff --git a/src-tauri/icons.backup/Square142x142Logo.png b/src-tauri/icons.backup/Square142x142Logo.png new file mode 100644 index 0000000..42f85b3 Binary files /dev/null and b/src-tauri/icons.backup/Square142x142Logo.png differ diff --git a/src-tauri/icons.backup/Square150x150Logo.png b/src-tauri/icons.backup/Square150x150Logo.png new file mode 100644 index 0000000..150d770 Binary files /dev/null and b/src-tauri/icons.backup/Square150x150Logo.png differ diff --git a/src-tauri/icons.backup/Square284x284Logo.png b/src-tauri/icons.backup/Square284x284Logo.png new file mode 100644 index 0000000..14c6e96 Binary files /dev/null and b/src-tauri/icons.backup/Square284x284Logo.png differ diff --git a/src-tauri/icons.backup/Square30x30Logo.png b/src-tauri/icons.backup/Square30x30Logo.png new file mode 100644 index 0000000..3331f16 Binary files /dev/null and b/src-tauri/icons.backup/Square30x30Logo.png differ diff --git a/src-tauri/icons.backup/Square310x310Logo.png b/src-tauri/icons.backup/Square310x310Logo.png new file mode 100644 index 0000000..ef47b52 Binary files /dev/null and b/src-tauri/icons.backup/Square310x310Logo.png differ diff --git a/src-tauri/icons.backup/Square44x44Logo.png b/src-tauri/icons.backup/Square44x44Logo.png new file mode 100644 index 0000000..055e6f3 Binary files /dev/null and b/src-tauri/icons.backup/Square44x44Logo.png differ diff --git a/src-tauri/icons.backup/Square71x71Logo.png b/src-tauri/icons.backup/Square71x71Logo.png new file mode 100644 index 0000000..d9477e5 Binary files /dev/null and b/src-tauri/icons.backup/Square71x71Logo.png differ diff --git a/src-tauri/icons.backup/Square89x89Logo.png b/src-tauri/icons.backup/Square89x89Logo.png new file mode 100644 index 0000000..7b93eac Binary files /dev/null and b/src-tauri/icons.backup/Square89x89Logo.png differ diff --git a/src-tauri/icons.backup/StoreLogo.png b/src-tauri/icons.backup/StoreLogo.png new file mode 100644 index 0000000..86dd4e3 Binary files /dev/null and b/src-tauri/icons.backup/StoreLogo.png differ diff --git a/src-tauri/icons.backup/icon.icns b/src-tauri/icons.backup/icon.icns new file mode 100644 index 0000000..bace5fc Binary files /dev/null and b/src-tauri/icons.backup/icon.icns differ diff --git a/src-tauri/icons.backup/icon.ico b/src-tauri/icons.backup/icon.ico new file mode 100644 index 0000000..7dbc9af Binary files /dev/null and b/src-tauri/icons.backup/icon.ico differ diff --git a/src-tauri/icons.backup/icon.png b/src-tauri/icons.backup/icon.png new file mode 100644 index 0000000..f6b09f4 Binary files /dev/null and b/src-tauri/icons.backup/icon.png differ diff --git a/src-tauri/icons.backup/icon.svg b/src-tauri/icons.backup/icon.svg new file mode 100644 index 0000000..c3586e2 --- /dev/null +++ b/src-tauri/icons.backup/icon.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png index 5fd9c3b..04d16f1 100644 Binary files a/src-tauri/icons/128x128.png and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png index 535bc6d..6c42f4c 100644 Binary files a/src-tauri/icons/128x128@2x.png and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png index 07ecddb..0015e4b 100644 Binary files a/src-tauri/icons/32x32.png and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/64x64.png b/src-tauri/icons/64x64.png index 7a525e3..5d6a60e 100644 Binary files a/src-tauri/icons/64x64.png and b/src-tauri/icons/64x64.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png index dbc52a6..09d0a38 100644 Binary files a/src-tauri/icons/Square107x107Logo.png and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png index 42f85b3..97c5c6b 100644 Binary files a/src-tauri/icons/Square142x142Logo.png and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png index 150d770..f5d0634 100644 Binary files a/src-tauri/icons/Square150x150Logo.png and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png index 14c6e96..219c15f 100644 Binary files a/src-tauri/icons/Square284x284Logo.png and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png index 3331f16..2c2717c 100644 Binary files a/src-tauri/icons/Square30x30Logo.png and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png index ef47b52..5d76017 100644 Binary files a/src-tauri/icons/Square310x310Logo.png and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png index 055e6f3..8d1c000 100644 Binary files a/src-tauri/icons/Square44x44Logo.png and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png index d9477e5..aa9d50f 100644 Binary files a/src-tauri/icons/Square71x71Logo.png and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png index 7b93eac..4336b63 100644 Binary files a/src-tauri/icons/Square89x89Logo.png and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png index 86dd4e3..fac3ced 100644 Binary files a/src-tauri/icons/StoreLogo.png and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns index bace5fc..d9532a6 100644 Binary files a/src-tauri/icons/icon.icns and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico index 7dbc9af..792eda3 100644 Binary files a/src-tauri/icons/icon.ico and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png index f6b09f4..140fea8 100644 Binary files a/src-tauri/icons/icon.png and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/src/commands/backup.rs b/src-tauri/src/commands/backup.rs new file mode 100644 index 0000000..2e0bac6 --- /dev/null +++ b/src-tauri/src/commands/backup.rs @@ -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 { + 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 { + 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 = 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 { + 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 = 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 { + 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 = 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 = 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 { + let mut args: Vec = 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; \ No newline at end of file diff --git a/src-tauri/src/commands/backup.test.rs b/src-tauri/src/commands/backup.test.rs new file mode 100644 index 0000000..2729980 --- /dev/null +++ b/src-tauri/src/commands/backup.test.rs @@ -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\"")); +} \ No newline at end of file diff --git a/src-tauri/src/commands/db_viewer.rs b/src-tauri/src/commands/db_viewer.rs index 4ad7b3c..1996003 100644 --- a/src-tauri/src/commands/db_viewer.rs +++ b/src-tauri/src/commands/db_viewer.rs @@ -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) { + let mut clauses = String::new(); + let mut params: Vec = 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) { + let mut clauses = String::new(); + let mut params: Vec = 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 = 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]) -> Vec /// 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>(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>(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 converts them regardless of column type OID. if let Ok(Some(v)) = row.try_get::<_, Option>(i) { return serde_json::Value::String(v); } @@ -581,22 +719,41 @@ pub async fn get_table_data( table: String, page: Option, page_size: Option, + filters: Option>, + sorts: Option>, state: State<'_, crate::AppState>, ) -> Result { 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, ¶m_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 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 = 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, ¶m_refs) + .await + .map_err(|e| e.to_string())? + }; let rows: Vec> = 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> = stmt - .query_map([], |row| { + let rows: Vec> = if filter_vals.is_empty() { + stmt.query_map([], |row| { let mut vals = Vec::new(); for i in 0..col_count { - let val: Option = 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 = 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> = 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 = 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, + state: State<'_, crate::AppState>, +) -> Result, 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>(2).unwrap_or_default(), + argument_types: r.get::<_, Option>>(3).unwrap_or_default(), + argument_names: r.get::<_, Option>>(4).unwrap_or_default(), + argument_modes: r.get::<_, Option>>(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, + state: State<'_, crate::AppState>, +) -> Result, 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, + state: State<'_, crate::AppState>, +) -> Result, 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>(2).unwrap_or_default(), + min_value: r.get::<_, Option>(3).unwrap_or_default(), + max_value: r.get::<_, Option>(4).unwrap_or_default(), + increment: r.get::<_, Option>(5).unwrap_or_default(), + current_value: r.get::<_, Option>(6).unwrap_or_default(), + cycle: r.get::<_, Option>(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, + state: State<'_, crate::AppState>, +) -> Result, 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>>(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, 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>(2).unwrap_or_default(), + comment: r.get(3), + }) + .collect()) + } + Some(DbHandle::Sqlite(_)) => Ok(vec![]), + None => Err("Connection not found".into()), + } +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- diff --git a/src-tauri/src/commands/keychain.rs b/src-tauri/src/commands/keychain.rs index cf2d110..fc0401e 100644 --- a/src-tauri/src/commands/keychain.rs +++ b/src-tauri/src/commands/keychain.rs @@ -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, 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( diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index b8b8dca..53e144f 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -7,4 +7,5 @@ pub mod import_export; pub mod test_connection; pub mod ssh; pub mod keychain; -pub mod demo; \ No newline at end of file +pub mod demo; +pub mod backup; \ No newline at end of file diff --git a/src-tauri/src/db/introspection.rs b/src-tauri/src/db/introspection.rs index 19f5f69..547155a 100644 --- a/src-tauri/src/db/introspection.rs +++ b/src-tauri/src/db/introspection.rs @@ -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")); + } } \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 611e339..aeaa8bc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -19,7 +19,7 @@ pub struct AppState { pub ssh_manager: StdMutex, } -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"); diff --git a/src-tauri/src/models/backup.rs b/src-tauri/src/models/backup.rs new file mode 100644 index 0000000..468100c --- /dev/null +++ b/src-tauri/src/models/backup.rs @@ -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, + pub tables: Option>, + 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, +} + +#[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, + pub tables: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PgToolStatus { + pub pg_dump_found: bool, + pub pg_restore_found: bool, + pub pg_dump_version: Option, + pub pg_restore_version: Option, +} + +#[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, + pub file_path: Option, + pub source_connection_id: Option, + pub status: String, + pub error_message: Option, + pub size_bytes: Option, + pub started_at: String, + pub completed_at: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct BackupProgressEvent { + pub job_id: String, + pub status: String, + pub progress: Option, + pub output_line: Option, + pub error: Option, +} + +#[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")); + } +} \ No newline at end of file diff --git a/src-tauri/src/models/db_viewer.rs b/src-tauri/src/models/db_viewer.rs index 771cd66..1a14f7c 100644 --- a/src-tauri/src/models/db_viewer.rs +++ b/src-tauri/src/models/db_viewer.rs @@ -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, + pub argument_names: Vec, + pub argument_modes: Vec, + pub language: String, + pub source: Option, + 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, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExtensionInfo { + pub name: String, + pub schema: String, + pub version: String, + pub comment: Option, +} + #[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")); + } } \ No newline at end of file diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 9b71c55..fa6797a 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -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}; \ No newline at end of file diff --git a/src-tauri/src/store/migrations.rs b/src-tauri/src/store/migrations.rs index f3d837e..98dbbb7 100644 --- a/src-tauri/src/store/migrations.rs +++ b/src-tauri/src/store/migrations.rs @@ -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); } } \ No newline at end of file diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index a0fae33..5cbc3db 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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", diff --git a/src/App.tsx b/src/App.tsx index 5caa66a..1d9a5df 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(() => { diff --git a/src/components/connections/ConnectionCard.test.tsx b/src/components/connections/ConnectionCard.test.tsx index 7f2d533..eed7d62 100644 --- a/src/components/connections/ConnectionCard.test.tsx +++ b/src/components/connections/ConnectionCard.test.tsx @@ -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 {children}; +} + describe("ConnectionCard", () => { beforeEach(() => { useUiStore.setState({ selectedItemIds: [] }); }); it("renders name and host", () => { - render(); + render(, { wrapper: Wrapper }); expect(screen.getByText("Prod DB")).toBeInTheDocument(); expect(screen.getByText("prod.example.com:5432")).toBeInTheDocument(); }); it("renders db type label", () => { - render(); + render(, { wrapper: Wrapper }); expect(screen.getByText(/postgresql/i)).toBeInTheDocument(); }); it("renders tag badges", () => { - render(); + render(, { wrapper: Wrapper }); expect(screen.getByText("production")).toBeInTheDocument(); expect(screen.getByText("primary")).toBeInTheDocument(); }); + it("renders drag handle", () => { + render(, { 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(); + render(, { 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(); + render(, { 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(); + render(, { 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(); + render(, { wrapper: Wrapper }); await user.click(screen.getByText("Prod DB")); // Should NOT open — should toggle selection instead expect(fn).not.toHaveBeenCalled(); diff --git a/src/components/connections/ConnectionCard.tsx b/src/components/connections/ConnectionCard.tsx index d309ca0..462a72c 100644 --- a/src/components/connections/ConnectionCard.tsx +++ b/src/components/connections/ConnectionCard.tsx @@ -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 (
+
+ +
@@ -58,18 +82,21 @@ function ConnectionCardBase({
{connection.name}
-
- {DB_LABELS[connection.db_type] ?? - connection.db_type} +
+ + {DB_LABELS[connection.db_type] ?? + connection.db_type} + + {connection.environment && ( + + {ENV_LABELS[connection.environment] ?? + connection.environment} + + )}
- {connection.environment && ( - - {ENV_LABELS[connection.environment] ?? connection.environment} - - )}
{hostLabel} diff --git a/src/components/connections/ConnectionGrid.tsx b/src/components/connections/ConnectionGrid.tsx index 48b586f..a9b88bb 100644 --- a/src/components/connections/ConnectionGrid.tsx +++ b/src/components/connections/ConnectionGrid.tsx @@ -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 ( +
+ + +
+ ); +} + 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 ( -
- - -
+ folder={f} + isSelected={isSelected} + count={count} + subfolderCount={subfolderCount} + folderTags={folderTags} + onFolderClick={handleFolderClick} + onToggleSelection={toggleItemSelection} + /> ); })} {directConnections.map((c) => ( diff --git a/src/components/db-viewer/BackupDialog.tsx b/src/components/db-viewer/BackupDialog.tsx new file mode 100644 index 0000000..60d3885 --- /dev/null +++ b/src/components/db-viewer/BackupDialog.tsx @@ -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 = { + 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("custom"); + const [filePath, setFilePath] = useState(""); + const [schema, setSchema] = useState(""); + const [noOwner, setNoOwner] = useState(true); + const [toolStatus, setToolStatus] = useState(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 = { + 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 ( + +
+

Backup Database

+ + {checkingTools && ( +

Checking for pg_dump...

+ )} + + {toolsMissing && ( +
+

pg_dump not found

+

+ The PostgreSQL client tools are required for backup/restore operations. Install them using: +

+
+              {getPlatformInstructions()}
+            
+
+ )} + + {!checkingTools && !toolsMissing && ( +
+ {/* Format selector */} +
+ + 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" + /> + +
+
+ + {/* Schema filter */} +
+ + 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" + /> +
+ + {/* No owner toggle */} + + + {/* Progress */} + {activeJob?.status === "running" && ( + + )} + + {/* Actions */} +
+ + +
+
+ )} +
+ + ); +} + +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; +} \ No newline at end of file diff --git a/src/components/db-viewer/BackupPage.tsx b/src/components/db-viewer/BackupPage.tsx new file mode 100644 index 0000000..38338a2 --- /dev/null +++ b/src/components/db-viewer/BackupPage.tsx @@ -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 = { + 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("custom"); + const [filePath, setFilePath] = useState(""); + const [schema, setSchema] = useState(""); + const [noOwner, setNoOwner] = useState(true); + const [toolStatus, setToolStatus] = useState(null); + const [checkingTools, setCheckingTools] = useState(true); + const [availableSchemas, setAvailableSchemas] = useState([]); + + 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(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 = { + 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 ( +
+ {/* Toolbar header */} +
+ + Backup + + Create a database backup via pg_dump + +
+ + {/* Content */} +
+
+ {/* Tool check */} + {checkingTools && ( +
+

+ Checking for pg_dump... +

+
+ )} + + {toolsMissing && ( +
+

+ pg_dump not found +

+

+ The PostgreSQL client tools are required for + backup/restore operations. Install them using: +

+
+                                {getPlatformInstructions()}
+                            
+
+ )} + + {!checkingTools && !toolsMissing && ( + <> + {/* Configuration card */} +
+ {/* Format */} +
+ + +
+ + {/* Output file */} +
+ +
+ + 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" + /> + +
+
+ + {/* Schema (optional) */} +
+ + +
+ + {/* No-owner toggle */} + +
+ + {/* Progress */} + {activeJob && ( +
+ +
+ )} + + {/* Actions */} +
+ +
+ + )} +
+
+
+ ); +} + diff --git a/src/components/db-viewer/BackupProgress.tsx b/src/components/db-viewer/BackupProgress.tsx new file mode 100644 index 0000000..92a4fde --- /dev/null +++ b/src/components/db-viewer/BackupProgress.tsx @@ -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 ( +
+ {/* Header row */} +
+
+ + {jobType} + + + {status === "running" && `In progress...`} + {status === "completed" && "Completed"} + {status === "failed" && "Failed"} + {status === "cancelled" && "Cancelled"} + +
+ {isRunning && onCancel && ( + + )} +
+ + {/* Progress bar */} +
+
+
+ + {/* Error message */} + {errorMessage && status === "failed" && ( +

{errorMessage}

+ )} +
+ ); +} diff --git a/src/components/db-viewer/DataGrid.test.tsx b/src/components/db-viewer/DataGrid.test.tsx deleted file mode 100644 index 6488f65..0000000 --- a/src/components/db-viewer/DataGrid.test.tsx +++ /dev/null @@ -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( {}} />); - 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( {}} />); - 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( {}} />); - 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( {}} />); - 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( {}} />); - 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( {}} />); - const nullCell = screen.getByText("NULL"); - expect(nullCell).toHaveClass("italic"); - expect(nullCell).toHaveClass("text-text-muted"); - }); -}); \ No newline at end of file diff --git a/src/components/db-viewer/DataGrid.tsx b/src/components/db-viewer/DataGrid.tsx deleted file mode 100644 index 636763c..0000000 --- a/src/components/db-viewer/DataGrid.tsx +++ /dev/null @@ -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; -type TabColumnWidths = Record; - -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; - selectedRows: Set; - onSelectionChange: (selected: Set) => 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({}); - - // 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(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 ( -
- Select a table to view data -
- ); - } - - if (!activeTab) { - return ( -
- Select a table to view data -
- ); - } - - if (activeTab.loading && !activeTab.data) { - return ( -
- Loading... -
- ); - } - - if (activeTab.error) { - return ( -
- {activeTab.error} -
- ); - } - - if (!activeTab.data) { - return ( -
- Loading table data... -
- ); - } - - const { columns } = activeTab.data; - - // Filter visible columns - const visibleColumns = hiddenColumns - ? columns.filter((c) => !hiddenColumns.has(c.name)) - : columns; - - return ( -
- {/* Loading indicator bar when refreshing with existing data */} - {activeTab.loading && ( -
- )} -
- - {/* Checkbox column */} - - {visibleColumns.map((col) => ( - - ))} - - - - {/* Header checkbox */} - - {visibleColumns.map((col) => ( - - ))} - - - - {rows.map((row, rowIndex) => { - const isSelected = selectedRows.has(rowIndex); - return ( - - {/* Row checkbox */} - - {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 ( - - ); - })} - - ); - })} - -
-
- -
-
-
- {col.is_pk && } - {col.is_fk && } - {col.name} - - {abbreviateType(col.data_type)} - -
- {/* resize handle */} -
startResize(col.name, e)} - onDoubleClick={() => { - setColWidths((prev) => ({ - ...prev, - [activeTabId!]: { ...(prev[activeTabId!] ?? {}), [col.name]: DEFAULT_COL_WIDTH }, - })); - }} - /> -
-
- toggleRow(rowIndex)} - className="w-3.5 h-3.5 rounded border-border cursor-pointer accent-accent" - /> -
-
-
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 ? ( - NULL - ) : isJson ? ( - - - {jp.label} - - ) : ( - String(cell) - )} -
-
- {/* FK preview popover */} - {fkPreview && ( - setFkPreview(null)} - /> - )} - {/* JSON cell popover */} - {jsonPopover && ( - setJsonPopover(null)} - /> - )} - - ); -} \ No newline at end of file diff --git a/src/components/db-viewer/DbViewerScreen.test.tsx b/src/components/db-viewer/DbViewerScreen.test.tsx index 1d3d1b0..a97cf6e 100644 --- a/src/components/db-viewer/DbViewerScreen.test.tsx +++ b/src/components/db-viewer/DbViewerScreen.test.tsx @@ -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({ diff --git a/src/components/db-viewer/DbViewerScreen.tsx b/src/components/db-viewer/DbViewerScreen.tsx index 37fc2fa..2f9665f 100644 --- a/src/components/db-viewer/DbViewerScreen.tsx +++ b/src/components/db-viewer/DbViewerScreen.tsx @@ -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(null); + const [currentView, setCurrentView] = useState("db-viewer"); + const [tablePanelWidth, setTablePanelWidth] = useState(280); + const [searchQuery, setSearchQuery] = useState(""); + const [selectedRows, setSelectedRows] = useState>(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(null); - const [tablePanelWidth, setTablePanelWidth] = useState(280); - const [hiddenColumns, setHiddenColumns] = useState>(new Set()); - const [filterRules, setFilterRules] = useState([]); - const [sortRules, setSortRules] = useState([]); - const [searchQuery, setSearchQuery] = useState(""); - const smartSortApplied = useRef>(new Set()); - const [selectedRows, setSelectedRows] = useState>(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>(new Set()); - - const fetchData = useCallback(async (tab: NonNullable) => { - 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>(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) => { + 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 ( - -
- -
- {connectionError && connectionError !== dismissedError && ( - { - setDismissedError(null); - connect(); - }} - onDismiss={() => setDismissedError(connectionError)} - /> - )} -
-
- setEditModalOpen(true)} - connectionId={connectionId} - searchQuery={searchQuery} - onSearchChange={setSearchQuery} - /> -
- -
-
- {/* panel resize handle */} -
setTablePanelWidth(280)} - /> -
- - {activeTab?.data && ( - - 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 ( + +
+ - )} -
- -
+
+ {connectionError && connectionError !== dismissedError && ( + { + setDismissedError(null); + connect(); + }} + onDismiss={() => setDismissedError(connectionError)} + /> + )} + {currentView === "db-viewer" ? ( +
+
+ setEditModalOpen(true)} + connectionId={connectionId} + searchQuery={searchQuery} + onSearchChange={setSearchQuery} + /> +
+ +
+
+ {/* panel resize handle */} +
setTablePanelWidth(280)} + /> +
+ + {activeTab?.data && ( + + 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()) + } + /> + )} +
+ { + 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, + ), + ); + }); + }} + /> +
+
+
+ ) : currentView === "functions" ? ( + + ) : currentView === "triggers" ? ( + + ) : currentView === "sequences" ? ( + + ) : currentView === "enums" ? ( + + ) : currentView === "extensions" ? ( + + ) : currentView === "backup" ? ( + + ) : currentView === "restore" ? ( + + ) : currentView === "sync" ? ( + + ) : null} + {currentView === "db-viewer" && } +
+ {currentConnection && ( + setEditModalOpen(false)} + onSaved={() => {}} + /> + )}
-
- -
- {currentConnection && ( - setEditModalOpen(false)} - onSaved={() => {}} - /> - )} -
- - ); -} \ No newline at end of file + + ); +} diff --git a/src/components/db-viewer/DbViewerSidebar.tsx b/src/components/db-viewer/DbViewerSidebar.tsx index ecfbfcc..2434957 100644 --- a/src/components/db-viewer/DbViewerSidebar.tsx +++ b/src/components/db-viewer/DbViewerSidebar.tsx @@ -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: }, - { id: "schema-visualizer", label: "Schema Visualizer coming soon", icon: , stub: true }, - { id: "functions", label: "Functions coming soon", icon: , stub: true }, - { id: "triggers", label: "Triggers coming soon", icon: , stub: true }, - ]; +export function DbViewerSidebar({ + currentView, + onNavigate, +}: DbViewerSidebarProps) { + const topItems: NavItem[] = [ + { id: "db-viewer", label: "Explorer", icon: }, + { + id: "schema-visualizer", + label: "Schema Visualizer coming soon", + icon: , + stub: true, + }, + { + id: "functions", + label: "Functions", + icon: , + }, + { id: "triggers", label: "Triggers", icon: }, + { + id: "sequences", + label: "Sequences", + icon: , + }, + { id: "enums", label: "Enums", icon: }, + { id: "extensions", label: "Extensions", icon: }, + { id: "backup", label: "Backup", icon: }, + { id: "restore", label: "Restore", icon: }, + { + id: "sync", + label: "DB Sync", + icon: , + }, + ]; - const bottomItems: NavItem[] = [ - { id: "home", label: "Home", icon: }, - { id: "settings", label: "Settings", icon: }, - ]; + const bottomItems: NavItem[] = [ + { id: "home", label: "Home", icon: }, + { id: "settings", label: "Settings", icon: }, + ]; - 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 ( + + + + ); + } return ( - - - +
+
+ {topItems.map(renderItem)} +
+
+ {bottomItems.map(renderItem)} +
+
); - } - - return ( -
-
{topItems.map(renderItem)}
-
{bottomItems.map(renderItem)}
-
- ); -} \ No newline at end of file +} diff --git a/src/components/db-viewer/DbViewerToolbar.tsx b/src/components/db-viewer/DbViewerToolbar.tsx index 14454a2..d34b9ad 100644 --- a/src/components/db-viewer/DbViewerToolbar.tsx +++ b/src/components/db-viewer/DbViewerToolbar.tsx @@ -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 | null>(null); - const searchInputRef = useRef(null); - const searchContainerRef = useRef(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 | null>(null); + const searchInputRef = useRef(null); + const searchContainerRef = useRef(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 ( -
-
- Tables -
- {onEdit && ( - - - - )} - - + + )} + + + + + + + + + +
+
+ {/* Search input */} +
- {refreshing ? ( - - ) : result === 'success' ? ( - - ) : result === 'error' ? ( - - ) : ( - - )} - - - - - - - - +
+ + 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 && ( + + )} +
+
+ {(databases.length > 1 || schemas.length > 1) && ( +
+ {databases.length > 1 && ( + ({ + value: d, + label: d, + }))} + placeholder="Select database" + aria-label="Select database" + variant="ghost" + /> + )} + {databases.length > 1 && schemas.length > 1 && ( + | + )} + {schemas.length > 1 && ( + ({ + value: s, + label: s, + }))} + placeholder="Select schema" + aria-label="Select schema" + variant="ghost" + /> + )} +
+ )}
-
- {/* Search input */} -
-
- - 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 && ( - - )} -
-
- {(databases.length > 1 || schemas.length > 1) && ( -
- {databases.length > 1 && ( - ({ value: d, label: d }))} - placeholder="Select database" - aria-label="Select database" - variant="ghost" - /> - )} - {databases.length > 1 && schemas.length > 1 && ( - | - )} - {schemas.length > 1 && ( - ({ value: s, label: s }))} - placeholder="Select schema" - aria-label="Select schema" - variant="ghost" - /> - )} -
- )} -
- ); -} \ No newline at end of file + ); +} diff --git a/src/components/db-viewer/ObjectExplorerPage.tsx b/src/components/db-viewer/ObjectExplorerPage.tsx new file mode 100644 index 0000000..f8cca1e --- /dev/null +++ b/src/components/db-viewer/ObjectExplorerPage.tsx @@ -0,0 +1,1163 @@ +import { useEffect, useState, useMemo, useCallback, useRef } from "react"; +import { + ChevronRight, + FunctionSquare, + GitBranch, + ListOrdered, + Tag, + Puzzle, + Search, + X, + RefreshCw, +} from "lucide-react"; +import { useDbViewerStore } from "../../stores/dbViewerStore"; +import { SelectDropdown } from "../ui/SelectDropdown"; +import * as cmd from "../../lib/commands"; +import type { + FunctionInfo, + TriggerInfo, + SequenceInfo, + EnumInfo, + ExtensionInfo, +} from "../../lib/types"; + +export type ObjectType = + | "functions" + | "triggers" + | "sequences" + | "enums" + | "extensions"; + +interface ObjectExplorerPageProps { + type: ObjectType; + connectionId: string; +} + +const TYPE_LABELS: Record = { + functions: "Functions", + triggers: "Triggers", + sequences: "Sequences", + enums: "Enums", + extensions: "Extensions", +}; + +const SINGULAR_LABELS: Record = { + functions: "function", + triggers: "trigger", + sequences: "sequence", + enums: "enum", + extensions: "extension", +}; + +const ICONS: Record = { + functions: ( + + ), + triggers: , + sequences: , + enums: , + extensions: , +}; + +type AnyObject = + | FunctionInfo + | TriggerInfo + | SequenceInfo + | EnumInfo + | ExtensionInfo; + +/** Build a unique key per item. Functions use their signature to disambiguate overloads. */ +function itemKey(item: AnyObject): string { + const name = (item as any).name as string; + if ("argument_types" in item && Array.isArray(item.argument_types)) { + return `${name}(${item.argument_types.join(",")})`; + } + return name; +} + +/** Display name for the tree list. Functions show their argument signature. */ +function itemLabel(item: AnyObject): string { + const name = (item as any).name as string; + if ( + "argument_types" in item && + Array.isArray(item.argument_types) && + item.argument_types.length > 0 + ) { + return `${name}(${item.argument_types.join(", ")})`; + } + return name; +} + +// ─── syntax highlighting for PL/pgSQL / SQL ────────────── + +const SQL_KEYWORDS = new Set([ + "ADD", + "ALL", + "ALTER", + "AND", + "ANY", + "AS", + "ASC", + "BEGIN", + "BETWEEN", + "BY", + "CALL", + "CASCADE", + "CASE", + "CAST", + "CHECK", + "CLOSE", + "COLLATE", + "COLUMN", + "COMMIT", + "CONSTRAINT", + "CONTINUE", + "CREATE", + "CROSS", + "CURRENT", + "CURSOR", + "DECLARE", + "DEFAULT", + "DELETE", + "DESC", + "DISTINCT", + "DO", + "DROP", + "ELSE", + "ELSIF", + "END", + "EXCEPTION", + "EXECUTE", + "EXISTS", + "EXIT", + "FETCH", + "FOR", + "FOREIGN", + "FROM", + "FULL", + "FUNCTION", + "GRANT", + "GROUP", + "HAVING", + "IF", + "IN", + "INDEX", + "INNER", + "INSERT", + "INTO", + "IS", + "JOIN", + "KEY", + "LANGUAGE", + "LEFT", + "LIMIT", + "LOOP", + "NOT", + "NULL", + "OF", + "OFFSET", + "ON", + "OPEN", + "OR", + "ORDER", + "OUTER", + "OVER", + "PERFORM", + "PLPGSQL", + "PRIMARY", + "PROCEDURE", + "QUERY", + "RAISE", + "REFERENCES", + "REPLACE", + "RETURN", + "RETURNS", + "REVOKE", + "RIGHT", + "ROLLBACK", + "ROW", + "ROWS", + "SCHEMA", + "SELECT", + "SET", + "STRICT", + "TABLE", + "THEN", + "TO", + "TRIGGER", + "UNION", + "UPDATE", + "USING", + "VALUES", + "VIEW", + "WHEN", + "WHERE", + "WHILE", + "WITH", +]); + +const SQL_TYPES = new Set([ + "BIGINT", + "BIGSERIAL", + "BIT", + "BOOL", + "BOOLEAN", + "BPCHAR", + "BYTEA", + "CHAR", + "CHARACTER", + "DATE", + "DECIMAL", + "DOUBLE", + "FLOAT", + "FLOAT4", + "FLOAT8", + "INT", + "INT2", + "INT4", + "INT8", + "INTEGER", + "INTERVAL", + "JSON", + "JSONB", + "MONEY", + "NAME", + "NUMERIC", + "OID", + "REAL", + "SERIAL", + "SMALLINT", + "TEXT", + "TIME", + "TIMESTAMP", + "TIMESTAMPTZ", + "UUID", + "VARBIT", + "VARCHAR", + "VOID", + "XML", +]); + +interface Token { + text: string; + kind: + | "keyword" + | "type" + | "string" + | "comment" + | "number" + | "operator" + | "plain"; +} + +function tokenizeLine(line: string): Token[] { + const tokens: Token[] = []; + let i = 0; + + while (i < line.length) { + if (/\s/.test(line[i])) { + let ws = ""; + while (i < line.length && /\s/.test(line[i])) { + ws += line[i]; + i++; + } + tokens.push({ text: ws, kind: "plain" }); + continue; + } + if (line[i] === "-" && line[i + 1] === "-") { + tokens.push({ text: line.slice(i), kind: "comment" }); + return tokens; + } + if (line[i] === "/" && line[i + 1] === "*") { + const end = line.indexOf("*/", i + 2); + if (end !== -1) { + tokens.push({ text: line.slice(i, end + 2), kind: "comment" }); + i = end + 2; + } else { + tokens.push({ text: line.slice(i), kind: "comment" }); + return tokens; + } + continue; + } + if (line[i] === "$") { + let dollar = ""; + const start = i; + while (i < line.length && line[i] === "$") { + dollar += "$"; + i++; + } + let tag = ""; + if (dollar.length === 1 && i < line.length && line[i] !== "$") { + while (i < line.length && line[i] !== "$") { + tag += line[i]; + i++; + } + if (line[i] === "$") { + i++; + dollar = `$${tag}$`; + } + } + const endTag = dollar; + const endIdx = line.indexOf(endTag, i); + if (endIdx !== -1) { + tokens.push({ + text: line.slice(start, endIdx + endTag.length), + kind: "string", + }); + i = endIdx + endTag.length; + } else { + tokens.push({ text: line.slice(start), kind: "string" }); + return tokens; + } + continue; + } + if (line[i] === "'") { + let str = "'"; + i++; + while (i < line.length) { + if (line[i] === "'" && line[i + 1] === "'") { + str += "''"; + i += 2; + continue; + } + if (line[i] === "'") { + str += "'"; + i++; + break; + } + str += line[i]; + i++; + } + tokens.push({ text: str, kind: "string" }); + continue; + } + if (/[0-9]/.test(line[i])) { + let num = ""; + while (i < line.length && /[0-9.]/.test(line[i])) { + num += line[i]; + i++; + } + tokens.push({ text: num, kind: "number" }); + continue; + } + if (/[=<>!+\-*/%&|^~@#;,.[\](){}]/.test(line[i])) { + let op = line[i]; + i++; + if (i < line.length) { + const pair = op + line[i]; + if ([":=", "=>", "<=", ">=", "<>", "||", "::"].includes(pair)) { + op = pair; + i++; + } + } + tokens.push({ text: op, kind: "operator" }); + continue; + } + let word = ""; + while (i < line.length && /[a-zA-Z_]/.test(line[i])) { + word += line[i]; + i++; + } + if (word) { + const upper = word.toUpperCase(); + if (SQL_KEYWORDS.has(upper)) { + tokens.push({ text: word, kind: "keyword" }); + } else if (SQL_TYPES.has(upper)) { + tokens.push({ text: word, kind: "type" }); + } else { + tokens.push({ text: word, kind: "plain" }); + } + } else { + // Catch-all for any character not matched above (non-ASCII, symbols, etc.) + tokens.push({ text: line[i], kind: "plain" }); + i++; + } + } + return tokens; +} + +function SyntaxCode({ + source, + language: _language, +}: { + source: string; + language?: string; +}) { + const [expanded, setExpanded] = useState(false); + const maxLines = 60; + + // Memoize the tokenized output — source doesn't change while viewing + const { displayLines, maxLineNum, truncated, totalLines } = useMemo(() => { + const lines: string[] = source.split("\n"); + const total: number = lines.length; + const isTruncated: boolean = !expanded && total > maxLines; + const display: string[] = isTruncated + ? lines.slice(0, maxLines) + : lines; + const maxNum: number = String(display.length).length; + const tokenized = display.map((line: string) => ({ + tokens: tokenizeLine(line), + })); + return { + displayLines: tokenized, + maxLineNum: maxNum, + truncated: isTruncated, + totalLines: total, + }; + }, [source, expanded]); + + const TOKEN_COLORS: Record = { + keyword: "text-blue-400", + type: "text-emerald-400", + string: "text-amber-300", + comment: "text-text-subtle italic", + number: "text-purple-400", + operator: "text-text-muted", + plain: "text-text", + }; + + return ( +
+
+
+                    {displayLines.map(
+                        (entry: { tokens: Token[] }, i: number) => {
+                            const { tokens } = entry;
+                            const num = String(i + 1).padStart(maxLineNum, " ");
+                            return (
+                                
+ + {num} + + + {tokens.length === 1 && + tokens[0].text.trim() === "" + ? "\u00A0" + : tokens.map((t, j) => ( + + {t.text} + + ))} + +
+ ); + }, + )} +
+
+ {truncated && ( +
+ +
+ )} + {expanded && totalLines > maxLines && ( +
+ +
+ )} +
+ ); +} + +function renderDetail(type: ObjectType, item: AnyObject) { + switch (type) { + case "functions": { + const f = item as FunctionInfo; + return ( +
+
+ + Signature + +
+
+
+ + Returns + + + {f.return_type || "void"} + +
+
+ + Language + + + {f.language} + +
+
+
+
+ + Kind + + + {f.kind === "f" ? "Function" : "Procedure"} + +
+
+ + Schema + + + {f.schema} + +
+
+ {f.argument_names.length > 0 && ( + <> +
+ + Arguments + + + {f.argument_names.length} total + +
+ {f.argument_names.map((name, i) => ( +
+
+ + {f.argument_modes?.[i] && + f.argument_modes[i] !== + "IN" && ( + + {f.argument_modes[i]} + + )} + #{i + 1} + +
+ + {name} + + : + + {f.argument_types?.[i] || "unknown"} + +
+ ))} + + )} + {f.source && ( + <> +
+ + Source + + + {f.language} + +
+ + + )} +
+ ); + } + case "triggers": { + const t = item as TriggerInfo; + return ( +
+
+ + Details + +
+
+
+ + Table + + + {t.table_schema}.{t.table_name} + +
+
+ + Event + + + {t.event_manipulation} + +
+
+
+
+ + Timing + + + {t.action_timing} {t.action_orientation} + +
+
+ + Status + + + {t.enabled === "O" + ? "Enabled" + : t.enabled === "D" + ? "Disabled" + : t.enabled} + +
+
+
+ + Schema + + + {t.schema} + +
+ {t.action_statement && ( + <> +
+ + Definition + + + SQL + +
+ + + )} +
+ ); + } + case "sequences": { + const s = item as SequenceInfo; + return ( +
+
+ + Sequence Values + +
+
+
+ + Current Value + + + {s.current_value} + +
+
+ + Increment + + + {s.increment} + +
+
+
+
+ + Start + + + {s.start_value} + +
+
+ + Min / Max + + + {s.min_value} / {s.max_value} + +
+
+
+ + Cycle + + + {s.cycle ? "Yes" : "No"} + +
+
+ ); + } + case "enums": { + const e = item as EnumInfo; + return ( +
+
+ + Details + +
+
+ + Schema + + + {e.schema} + +
+
+ + Values + + + {e.labels.length} labels + +
+ {e.labels.map((label, i) => ( +
+ + #{i + 1} + + + {label} + +
+ ))} +
+ ); + } + case "extensions": { + const e = item as ExtensionInfo; + return ( +
+
+ + Extension + +
+
+
+ + Version + + + {e.version} + +
+
+ + Schema + + + {e.schema} + +
+
+ {e.comment && ( +
+ + Comment + +

+ {e.comment} +

+
+ )} +
+ ); + } + } +} + +export function ObjectExplorerPage({ + type, + connectionId, +}: ObjectExplorerPageProps) { + const [panelWidth, setPanelWidth] = useState(280); + const panelResizeRef = useRef<{ startX: number; startW: number } | null>( + null, + ); + + const onPanelResizeStart = useCallback( + (e: React.MouseEvent) => { + panelResizeRef.current = { startX: e.clientX, startW: panelWidth }; + const onMove = (ev: MouseEvent) => { + if (!panelResizeRef.current) return; + const delta = ev.clientX - panelResizeRef.current.startX; + const next = Math.max( + 180, + Math.min(500, panelResizeRef.current.startW + delta), + ); + setPanelWidth(next); + }; + const onUp = () => { + panelResizeRef.current = null; + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }, + [panelWidth], + ); + + 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); + + // Use store for persistence, but allow re-fetching when schema changes + const [items, setItems] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [selectedItem, setSelectedItem] = useState(null); + const [searchOpen, setSearchOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const searchInputRef = useRef(null); + const searchContainerRef = useRef(null); + + // Track last-fetched-schema so we know when to re-fetch + const lastSchemaRef = useRef(undefined); + + // Fetch on mount and when schema changes + const fetch = useCallback(async () => { + setLoading(true); + setError(null); + try { + let result: AnyObject[]; + if (type === "extensions") { + result = await cmd.getExtensions(connectionId); + } else if (type === "functions") { + result = await cmd.getFunctions( + connectionId, + currentSchema ?? undefined, + ); + } else if (type === "triggers") { + result = await cmd.getTriggers( + connectionId, + currentSchema ?? undefined, + ); + } else if (type === "sequences") { + result = await cmd.getSequences( + connectionId, + currentSchema ?? undefined, + ); + } else if (type === "enums") { + result = await cmd.getEnums( + connectionId, + currentSchema ?? undefined, + ); + } else { + result = []; + } + setItems(result); + setSelectedItem(null); + lastSchemaRef.current = currentSchema ?? undefined; + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + setItems(null); + } finally { + setLoading(false); + } + }, [type, connectionId, currentSchema]); + + useEffect(() => { + // Only re-fetch if schema actually changed (or first load) + if (lastSchemaRef.current !== (currentSchema ?? undefined)) { + fetch(); + } + }, [currentSchema, fetch]); + + // Search toggle handling + useEffect(() => { + if (searchOpen && searchInputRef.current) { + searchInputRef.current.focus(); + } + }, [searchOpen]); + + const handleSearchBlur = useCallback(() => { + setTimeout(() => { + if (!searchQuery.trim()) { + setSearchOpen(false); + } + }, 150); + }, [searchQuery]); + + const toggleSearch = useCallback(() => { + setSearchOpen((prev) => { + const next = !prev; + if (!next) setSearchQuery(""); + return next; + }); + }, []); + + const q = searchQuery.toLowerCase().trim(); + const filtered = useMemo(() => { + if (!items) return []; + if (!q) return items; + return items.filter((item) => { + const label = itemLabel(item).toLowerCase(); + return label.includes(q); + }); + }, [items, q]); + + const icon = ICONS[type]; + const label = TYPE_LABELS[type]; + const singular = SINGULAR_LABELS[type]; + + return ( +
+ {/* Left panel: toolbar + object list */} +
+
+
+ + {label} + +
+ + +
+
+ + {/* Search input */} +
+
+ + setSearchQuery(e.target.value)} + onBlur={handleSearchBlur} + placeholder={`Filter ${label.toLowerCase()}…`} + 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 && ( + + )} +
+
+ + {/* Database/Schema dropdowns */} + {(databases.length > 1 || schemas.length > 1) && ( +
+ {databases.length > 1 && ( + + setCurrentDatabase(v || null) + } + options={databases.map((d) => ({ + value: d, + label: d, + }))} + placeholder="Select database" + aria-label="Select database" + variant="ghost" + /> + )} + {databases.length > 1 && schemas.length > 1 && ( + | + )} + {schemas.length > 1 && ( + + setCurrentSchema(v || null) + } + options={schemas.map((s) => ({ + value: s, + label: s, + }))} + placeholder="Select schema" + aria-label="Select schema" + variant="ghost" + /> + )} +
+ )} +
+ + {/* Object list */} +
+ {loading && ( +
+ + Loading {label.toLowerCase()}... +
+ )} + + {error && ( +
+ {error} +
+ )} + + {!loading && !error && filtered.length === 0 && ( +
+ {items === null + ? `No ${label.toLowerCase()} found` + : searchQuery + ? `No ${label.toLowerCase()} matching "${searchQuery}"` + : `No ${label.toLowerCase()} found in ${currentSchema || "current schema"}`} +
+ )} + + {!loading && + filtered.map((item) => { + const name = itemLabel(item); + const key = itemKey(item); + const isSelected = + selectedItem !== null && + itemKey(selectedItem) === itemKey(item); + + return ( +
setSelectedItem(item)} + className={`group flex items-center gap-1 px-3 py-1 cursor-pointer transition-colors ${ + isSelected + ? "bg-accent/10 text-accent" + : "text-text hover:bg-surface-raised" + }`} + > + {icon} + + {name} + + +
+ ); + })} +
+
+ + {/* Panel resize handle */} +
setPanelWidth(280)} + /> + + {/* Right panel: detail view */} +
+ {selectedItem ? ( + <> + {/* Header */} +
+

+ {itemLabel(selectedItem)} +

+

+ {singular} + {"schema" in selectedItem + ? ` · ${(selectedItem as any).schema}` + : ""} +

+
+ + {/* Detail content */} +
+ {renderDetail(type, selectedItem)} +
+ + ) : ( +
+
+
+ {icon} +
+

+ Select a {singular} to view details +

+

+ {filtered.length} {label.toLowerCase()}{" "} + available +

+
+
+ )} +
+
+ ); +} diff --git a/src/components/db-viewer/ObjectTree.tsx b/src/components/db-viewer/ObjectTree.tsx new file mode 100644 index 0000000..5bbeb4d --- /dev/null +++ b/src/components/db-viewer/ObjectTree.tsx @@ -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 = { + functions: "functions", + triggers: "triggers", + sequences: "sequences", + enums: "enums", + extensions: "extensions", +}; + +const ICONS: Record = { + functions: , + triggers: , + sequences: , + enums: , + extensions: , +}; + +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 ( +
+
+        {display}
+        {truncated && ...}
+      
+ {source.length > maxLen && ( + + )} +
+ ); +} + +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>(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 ( +
+ {/* Search bar */} +
+
+ + 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" + /> +
+
+ + {/* Content */} +
+ {loading && ( +
+
+ Loading {pluralLabel}... +
+ )} + + {!loading && list.length === 0 && ( +
+ {data === null ? `No ${pluralLabel} found` : `No ${pluralLabel} found`} +
+ )} + + {!loading && list.map((item) => { + const name = "name" in item ? (item as { name: string }).name : ""; + const key = name; + const isExpanded = expandedKeys.has(key); + + return ( +
+
toggle(key)} + > + + {icon} + + {name} + +
+ + {isExpanded && ( +
+ {type === "functions" && (() => { + const f = item as FunctionInfo; + return ( + <> +
+ Returns: {f.return_type} +
+
+ Language: {f.language} +
+ {f.argument_names.length > 0 && ( +
+ Args:{" "} + {f.argument_names.map((a, i) => ( + + {f.argument_modes?.[i] && f.argument_modes[i] !== "IN" && ( + {f.argument_modes[i]} + )} + {a} ({f.argument_types?.[i] || "unknown"}) + {i < f.argument_names.length - 1 && ", "} + + ))} +
+ )} + {f.source && } + + ); + })()} + + {type === "triggers" && (() => { + const t = item as TriggerInfo; + return ( + <> +
+ Table: {t.table_schema}.{t.table_name} +
+
+ Event: {t.event_manipulation} +
+
+ Timing: {t.action_timing} +
+
+ Orientation: {t.action_orientation} +
+
+ Enabled: {t.enabled} +
+ {t.action_statement && } + + ); + })()} + + {type === "sequences" && (() => { + const s = item as SequenceInfo; + return ( + <> +
+ Current: {s.current_value} +
+
+ Increment: {s.increment} +
+
+ Min: {s.min_value} +
+
+ Max: {s.max_value} +
+
+ Start: {s.start_value} +
+
+ Cycle: {s.cycle ? "Yes" : "No"} +
+ + ); + })()} + + {type === "enums" && (() => { + const e = item as EnumInfo; + return ( +
+ {e.labels.map((label) => ( + + {label} + + ))} +
+ ); + })()} + + {type === "extensions" && (() => { + const e = item as ExtensionInfo; + return ( + <> +
+ Version: {e.version} +
+
+ Schema: {e.schema} +
+ {e.comment && ( +
+ Comment: {e.comment} +
+ )} + + ); + })()} +
+ )} +
+ ); + })} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/db-viewer/RestoreDialog.tsx b/src/components/db-viewer/RestoreDialog.tsx new file mode 100644 index 0000000..7c47cdb --- /dev/null +++ b/src/components/db-viewer/RestoreDialog.tsx @@ -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 = { + 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(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 ( + +
+

Restore Database

+ + {checkingTools && ( +

Checking for pg_restore...

+ )} + + {toolsMissing && ( +
+

pg_restore not found

+

+ The PostgreSQL client tools are required for backup/restore operations. Install them using: +

+
+              {getPlatformInstructions()}
+            
+
+ )} + + {!checkingTools && !toolsMissing && ( +
+ {/* File path */} +
+ +
+ 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" + /> + +
+
+ + {/* Format */} +
+ + +
+ + {/* Schema filter */} +
+ + 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" + /> +
+ + {/* Clean toggle */} + + + {/* Destructive confirmation */} +
+ +
+ + {/* Progress */} + {activeJob?.status === "running" && ( + + )} + + {/* Actions */} +
+ + +
+
+ )} +
+
+ ); +} + +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; +} \ No newline at end of file diff --git a/src/components/db-viewer/RestorePage.tsx b/src/components/db-viewer/RestorePage.tsx new file mode 100644 index 0000000..cdd9e08 --- /dev/null +++ b/src/components/db-viewer/RestorePage.tsx @@ -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 = { + 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(null); + const [checkingTools, setCheckingTools] = useState(true); + const [availableSchemas, setAvailableSchemas] = useState([]); + + 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(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 ( +
+ {/* Toolbar header */} +
+ + Restore + + Restore a database from a backup file + +
+ + {/* Content */} +
+
+ {/* Tool check */} + {checkingTools && ( +
+

+ Checking for pg_restore... +

+
+ )} + + {toolsMissing && ( +
+

+ pg_restore not found +

+

+ The PostgreSQL client tools are required for + backup/restore operations. Install them using: +

+
+                                {getPlatformInstructions()}
+                            
+
+ )} + + {!checkingTools && !toolsMissing && ( + <> + {/* Configuration card */} +
+ {/* Format */} +
+ + +
+ + {/* Backup file */} +
+ +
+ + 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" + /> + +
+
+ + {/* Schema (optional) */} +
+ + +
+ + {/* Clean toggle */} + +
+ + {/* Destructive confirmation */} +
+ +
+ + {/* Progress */} + {activeJob && ( +
+ +
+ )} + + {/* Actions */} +
+ +
+ + )} +
+
+
+ ); +} + diff --git a/src/components/db-viewer/SyncDialog.tsx b/src/components/db-viewer/SyncDialog.tsx new file mode 100644 index 0000000..8fcec0f --- /dev/null +++ b/src/components/db-viewer/SyncDialog.tsx @@ -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(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 ( + +
+

Sync Databases

+ + {checkingTools && ( +

Checking for pg_dump/pg_restore...

+ )} + + {toolsMissing && ( +
+

PostgreSQL tools not found

+

+ Both pg_dump and pg_restore are required for database sync. +

+ {!toolStatus?.pg_dump_found && ( +

pg_dump is missing.

+ )} + {!toolStatus?.pg_restore_found && ( +

pg_restore is missing.

+ )} +
+ )} + + {!checkingTools && !toolsMissing && ( +
+ {/* Source connection */} +
+ + +
+ + {/* Target connection */} +
+ + +
+ + {/* Schema filter */} +
+ + 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" + /> +
+ + {/* Destructive confirmation */} +
+ +
+ + {/* Progress */} + {activeJob?.status === "running" && ( + + )} + + {/* Actions */} +
+ + +
+
+ )} +
+
+ ); +} + +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; +} \ No newline at end of file diff --git a/src/components/db-viewer/SyncPage.tsx b/src/components/db-viewer/SyncPage.tsx new file mode 100644 index 0000000..7202298 --- /dev/null +++ b/src/components/db-viewer/SyncPage.tsx @@ -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(null); + const [checkingTools, setCheckingTools] = useState(true); + const [availableSchemas, setAvailableSchemas] = useState([]); + + 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(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 ( +
+ {/* Toolbar header */} +
+ + DB Sync + + Transfer data between PostgreSQL databases via pipe + +
+ + {/* Content */} +
+
+ {/* Tool check */} + {checkingTools && ( +
+

+ Checking for pg_dump / pg_restore... +

+
+ )} + + {toolsMissing && ( +
+

+ PostgreSQL tools not found +

+

+ Both pg_dump and pg_restore are required for + database sync. +

+
    + {!toolStatus?.pg_dump_found && ( +
  • pg_dump is missing.
  • + )} + {!toolStatus?.pg_restore_found && ( +
  • pg_restore is missing.
  • + )} +
+
+ )} + + {!checkingTools && !toolsMissing && ( + <> + {/* Configuration card */} +
+ {/* Source & Target connection pickers */} +
+
+ + +
+
+ + +
+
+ + {/* Schema (optional) */} +
+ + +
+ + {/* Flow indicator */} + {sourceConnectionId && targetConnectionId && ( +
+ + {postgresqlConnections.find( + (c) => + c.id === sourceConnectionId, + )?.name ?? sourceConnectionId} + + + + {postgresqlConnections.find( + (c) => + c.id === targetConnectionId, + )?.name ?? targetConnectionId} + +
+ )} +
+ + {/* Destructive confirmation */} +
+ +
+ + {/* Progress */} + {activeJob && ( +
+ +
+ )} + + {/* Actions */} +
+ +
+ + )} +
+
+
+ ); +} + diff --git a/src/components/db-viewer/TabBar.tsx b/src/components/db-viewer/TabBar.tsx index d1a7902..697f7fe 100644 --- a/src/components/db-viewer/TabBar.tsx +++ b/src/components/db-viewer/TabBar.tsx @@ -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(" ")} > - + - - - {table.name} - -
e.stopPropagation()}> - -
-
- {isExpanded && ( -
- {cols.length === 0 && ( -
No columns
- )} - {cols.map((col) => ( -
- {col.is_pk ? ( - - ) : col.is_fk ? ( - - ) : ( - - )} - {col.name} - {abbreviateType(col.data_type)} -
- ))} -
+ 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 ( +
+ {filteredTables.length === 0 && ( +
+ No tables +
)} -
- ); - })} -
- ); -} \ No newline at end of file + {filteredTables.map((table) => { + const key = `${table.schema}.${table.name}`; + const isExpanded = expanded.has(key); + const cols = columnCache[key] ?? table.columns ?? []; + return ( +
+
openTab(table.schema, table.name)} + > + + + + {table.name} + +
e.stopPropagation()}> + +
+
+ {isExpanded && ( +
+ {cols.length === 0 && ( +
+ No columns +
+ )} + {cols.map((col) => ( +
+ {col.is_pk ? ( + + ) : col.is_fk ? ( + + ) : ( + + )} + + {col.name} + + + {abbreviateType(col.data_type)} + +
+ ))} +
+ )} +
+ ); + })} +
+ ); +} diff --git a/src/components/folders/FolderTree.test.tsx b/src/components/folders/FolderTree.test.tsx index 3f0b46e..7cca63c 100644 --- a/src/components/folders/FolderTree.test.tsx +++ b/src/components/folders/FolderTree.test.tsx @@ -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 {children}; +} + 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( {}} />); + render( {}} />, { 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(); + render(, { 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(); + render(, { wrapper: Wrapper }); await user.click(screen.getByText("Work")); expect(fn).toHaveBeenCalledWith("f1"); }); diff --git a/src/components/folders/FolderTree.tsx b/src/components/folders/FolderTree.tsx index dad8c63..3517b03 100644 --- a/src/components/folders/FolderTree.tsx +++ b/src/components/folders/FolderTree.tsx @@ -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 (
- {childrenOf(folder.id).map((c) => renderFolder(c, depth + 1))} + {childrenOf(folder.id).map((c) => ( + + ))}
); }; @@ -30,12 +43,17 @@ export function FolderTree({ folders, activeFolderId, onSelect }: FolderTreeProp return (
- {roots.map((r) => renderFolder(r, 0))} + {roots.map((r) => ( + + ))}
); } \ No newline at end of file diff --git a/src/components/grid/VirtualDataGrid.test.tsx b/src/components/grid/VirtualDataGrid.test.tsx new file mode 100644 index 0000000..da2c823 --- /dev/null +++ b/src/components/grid/VirtualDataGrid.test.tsx @@ -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( + {}} + 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( + {}} + 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( + {}} + 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( + {}} + 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( { 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( {}} 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( {}} onToggleAll={() => {}} />); + + expect(screen.getByText(/2 keys/)).toBeInTheDocument(); + }); + + it("has resize handles on column headers", () => { + mockGetTotalSize.mockReturnValue(0); + mockGetVirtualItems.mockReturnValue([]); + + render( {}} 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( + {}} 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( + {}} 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( + {}} onToggleAll={() => {}} />, + ); + expect(screen.queryByText("name")).not.toBeInTheDocument(); + expect(screen.getByText("id")).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/src/components/grid/VirtualDataGrid.tsx b/src/components/grid/VirtualDataGrid.tsx new file mode 100644 index 0000000..afcfa27 --- /dev/null +++ b/src/components/grid/VirtualDataGrid.tsx @@ -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; + selectedRows: Set; + 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(null); + + const visibleColumns = columns.filter((c) => !hiddenColumns.has(c.name)); + const allSelected = rows.length > 0 && selectedRows.size === rows.length; + const selectAllRef = useRef(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>({}); + + 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 ( +
{ + 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 ? ( + NULL + ) : isJson ? ( + + + {jp.label} + + ) : ( + String(cell) + )} +
+ ); + }, + [columns, getWidth, handleFkClick], + ); + + return ( +
+ {/* ── sticky header ── */} +
+
+
+ +
+ {visibleColumns.map((col) => ( +
+
+ {col.is_pk && } + {col.is_fk && } + {col.name} + + {abbreviateType(col.data_type)} + +
+
startResize(col.name, e)} + onDoubleClick={() => resetWidth(col.name)} + /> +
+ ))} +
+
+ + {/* ── virtual body ── */} + {rows.length === 0 ? ( +
+ No rows in result set +
+ ) : ( +
+ {virtualizer.getVirtualItems().map((virtualRow) => { + const row = rows[virtualRow.index]; + const isSelected = selectedRows.has(virtualRow.index); + return ( +
+
+ onToggleRow(virtualRow.index)} + className="w-3.5 h-3.5 rounded border-border cursor-pointer accent-accent" + /> +
+ {visibleColumns.map((col) => renderCell(col, row, virtualRow.index))} +
+ ); + })} +
+ )} + + {/* FK preview popover */} + {fkPreview && ( + setFkPreview(null)} + /> + )} + {/* JSON cell popover */} + {jsonPopover && ( + setJsonPopover(null)} + /> + )} +
+ ); +} \ No newline at end of file diff --git a/src/components/layout/HomeScreen.tsx b/src/components/layout/HomeScreen.tsx index d641d64..27338a7 100644 --- a/src/components/layout/HomeScreen.tsx +++ b/src/components/layout/HomeScreen.tsx @@ -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(null); const searchRef = useRef(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} />
- 0} - onTagToggle={toggleTag} - onOpenDbViewer={handleOpenDbViewer} - onEditFolder={(f) => setEditFolder(f)} - onDeleteFolder={(f) => - setConfirmDelete({ type: "folder", folder: f }) - } - /> + setActiveDragId(event.active.id as string)} + onDragEnd={async (event) => { + setActiveDragId(null); + await handleDragEnd(event); + }} + collisionDetection={closestCenter} + > + 0} + onTagToggle={toggleTag} + onOpenDbViewer={handleOpenDbViewer} + onEditFolder={(f) => setEditFolder(f)} + onDeleteFolder={(f) => + setConfirmDelete({ type: "folder", folder: f }) + } + /> + + {activeDragId && connections.find((c) => c.id === activeDragId) ? ( +
+ c.id === activeDragId)!} + tags={tags} + onTagToggle={() => {}} + onOpenDbViewer={() => {}} + /> +
+ ) : null} +
+
{ - return invoke("get_table_data", { connectionId, schema, table, page, pageSize }); + return invoke("get_table_data", { connectionId, schema, table, page, pageSize, filters, sorts }); } export async function executeChange(connectionId: string, change: ChangeItem): Promise { @@ -96,4 +99,44 @@ export async function getFkPreview( export async function refreshConnection(connectionId: string): Promise { return invoke("refresh_connection", { connectionId }); +} + +// ─── Backup / Restore / Sync ────────────────────────────────── + +export async function detectPgTools(): Promise { + return invoke("detect_pg_tools"); +} + +export async function pgDump(connectionId: string, options: BackupOptions): Promise { + return invoke("pg_dump", { connectionId, options }); +} + +export async function pgRestore(connectionId: string, options: RestoreOptions): Promise { + return invoke("pg_restore", { connectionId, options }); +} + +export async function dbSync(options: SyncOptions): Promise { + return invoke("db_sync", { options }); +} + +// ─── Object Explorer (Functions, Triggers, Sequences, Enums, Extensions) ──── + +export async function getFunctions(connectionId: string, schema?: string): Promise { + return invoke("get_functions", { connectionId, schema }); +} + +export async function getTriggers(connectionId: string, schema?: string): Promise { + return invoke("get_triggers", { connectionId, schema }); +} + +export async function getSequences(connectionId: string, schema?: string): Promise { + return invoke("get_sequences", { connectionId, schema }); +} + +export async function getEnums(connectionId: string, schema?: string): Promise { + return invoke("get_enums", { connectionId, schema }); +} + +export async function getExtensions(connectionId: string): Promise { + return invoke("get_extensions", { connectionId }); } \ No newline at end of file diff --git a/src/lib/types.ts b/src/lib/types.ts index 3bb6b60..a8f78b5 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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; } \ No newline at end of file diff --git a/src/stores/backupStore.ts b/src/stores/backupStore.ts new file mode 100644 index 0000000..b8a5b00 --- /dev/null +++ b/src/stores/backupStore.ts @@ -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; +} + +export const useBackupStore = create((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("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"); + } + }); + }, +})); \ No newline at end of file diff --git a/src/stores/connectionStore.test.ts b/src/stores/connectionStore.test.ts index 2284509..f64c306 100644 --- a/src/stores/connectionStore.test.ts +++ b/src/stores/connectionStore.test.ts @@ -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(); + }); }); \ No newline at end of file diff --git a/src/stores/connectionStore.ts b/src/stores/connectionStore.ts index a4da8fe..8e2c8c0 100644 --- a/src/stores/connectionStore.ts +++ b/src/stores/connectionStore.ts @@ -18,6 +18,7 @@ interface ConnectionState { updateTag: (id: string, input: TagInput) => Promise; deleteTag: (id: string) => Promise; addTagToItems: (tagId: string, folderIds: string[], connectionIds: string[]) => Promise; + moveConnection: (connectionId: string, newFolderId: string | null) => Promise; cachePassword: (connectionId: string, password: string) => Promise; getConnectionPassword: (connectionId: string) => Promise; } @@ -116,4 +117,47 @@ export const useConnectionStore = create((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; + } + }, })); \ No newline at end of file diff --git a/src/stores/dbViewerStore.ts b/src/stores/dbViewerStore.ts index e3d40d0..9371e8a 100644 --- a/src/stores/dbViewerStore.ts +++ b/src/stores/dbViewerStore.ts @@ -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((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((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 }),